@camstack/ui-library 1.2.190 → 1.2.191

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.js CHANGED
@@ -1167,6 +1167,30 @@ var CircleX = createLucideIcon("circle-x", [
1167
1167
  * This source code is licensed under the ISC license.
1168
1168
  * See the LICENSE file in the root directory of this source tree.
1169
1169
  */
1170
+ var Clapperboard = createLucideIcon("clapperboard", [
1171
+ ["path", {
1172
+ d: "m12.296 3.464 3.02 3.956",
1173
+ key: "qash78"
1174
+ }],
1175
+ ["path", {
1176
+ d: "M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3z",
1177
+ key: "1h7j8b"
1178
+ }],
1179
+ ["path", {
1180
+ d: "M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
1181
+ key: "4lm6w1"
1182
+ }],
1183
+ ["path", {
1184
+ d: "m6.18 5.276 3.1 3.899",
1185
+ key: "zjj9t3"
1186
+ }]
1187
+ ]);
1188
+ /**
1189
+ * @license lucide-react v0.576.0 - ISC
1190
+ *
1191
+ * This source code is licensed under the ISC license.
1192
+ * See the LICENSE file in the root directory of this source tree.
1193
+ */
1170
1194
  var Clock = createLucideIcon("clock", [["circle", {
1171
1195
  cx: "12",
1172
1196
  cy: "12",
@@ -17581,360 +17605,1114 @@ function useCapabilityPresence(capabilityName) {
17581
17605
  return classifyCapability(query.data);
17582
17606
  }
17583
17607
  //#endregion
17584
- //#region src/contexts/vod-playback.tsx
17608
+ //#region ../../node_modules/class-variance-authority/dist/index.mjs
17585
17609
  /**
17586
- * VodPlayback — shared context coordinating recorded (VOD) playback between a
17587
- * widget/section (the Recording panel) and the hero StreamPanel.
17610
+ * Copyright 2022 Joe Bell. All rights reserved.
17588
17611
  *
17589
- * A CONTROLLER (the Recording widget) calls `play({ url, label })`; the hero
17590
- * StreamPanel CONSUMES `request` and switches into VOD mode (HLS playback
17591
- * through the hub data-plane) instead of the live WebRTC stream. `clear()`
17592
- * returns the hero to live.
17612
+ * This file is licensed to you under the Apache License, Version 2.0
17613
+ * (the "License"); you may not use this file except in compliance with the
17614
+ * License. You may obtain a copy of the License at
17593
17615
  *
17594
- * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
17595
- * the Module-Federation boundary: an addon-shipped widget consuming
17596
- * `useVodPlayback()` resolves the SAME provider instance the host mounted —
17597
- * the same mechanism `player-overlays` / `zone-editing` use to let federated
17598
- * widgets (zone-editor, privacy-mask) drive the live player. Consumers used
17599
- * outside a provider get the inert default (no-op), so a StreamPanel mounted
17600
- * on a page without the Recording widget stays live-only without crashing.
17601
- */
17602
- var VodPlaybackContext = createSharedContext("camstack:vod-playback", {
17603
- request: null,
17604
- play: () => {},
17605
- clear: () => {}
17616
+ * http://www.apache.org/licenses/LICENSE-2.0
17617
+ *
17618
+ * Unless required by applicable law or agreed to in writing, software
17619
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
17620
+ * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the
17621
+ * License for the specific language governing permissions and limitations under
17622
+ * the License.
17623
+ */ var falsyToString = (value) => typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value;
17624
+ var cx = clsx;
17625
+ var cva = (base, config) => (props) => {
17626
+ var _config_compoundVariants;
17627
+ if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
17628
+ const { variants, defaultVariants } = config;
17629
+ const getVariantClassNames = Object.keys(variants).map((variant) => {
17630
+ const variantProp = props === null || props === void 0 ? void 0 : props[variant];
17631
+ const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];
17632
+ if (variantProp === null) return null;
17633
+ const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);
17634
+ return variants[variant][variantKey];
17635
+ });
17636
+ const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param) => {
17637
+ let [key, value] = param;
17638
+ if (value === void 0) return acc;
17639
+ acc[key] = value;
17640
+ return acc;
17641
+ }, {});
17642
+ return cx(base, getVariantClassNames, config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param) => {
17643
+ let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;
17644
+ return Object.entries(compoundVariantOptions).every((param) => {
17645
+ let [key, value] = param;
17646
+ return Array.isArray(value) ? value.includes({
17647
+ ...defaultVariants,
17648
+ ...propsWithoutUndefined
17649
+ }[key]) : {
17650
+ ...defaultVariants,
17651
+ ...propsWithoutUndefined
17652
+ }[key] === value;
17653
+ }) ? [
17654
+ ...acc,
17655
+ cvClass,
17656
+ cvClassName
17657
+ ] : acc;
17658
+ }, []), props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
17659
+ };
17660
+ //#endregion
17661
+ //#region src/primitives/badge.tsx
17662
+ var badgeVariants = cva("inline-flex items-center rounded-full text-xs font-medium px-2 py-0.5", {
17663
+ variants: {
17664
+ variant: {
17665
+ default: "bg-surface-hover text-foreground",
17666
+ success: "bg-success/15 text-success",
17667
+ warning: "bg-warning/15 text-warning",
17668
+ danger: "bg-danger/15 text-danger",
17669
+ info: "bg-info/15 text-info"
17670
+ },
17671
+ styleVariant: {
17672
+ solid: "",
17673
+ outline: "border bg-transparent"
17674
+ }
17675
+ },
17676
+ defaultVariants: {
17677
+ variant: "default",
17678
+ styleVariant: "solid"
17679
+ }
17606
17680
  });
17607
- function VodPlaybackProvider({ children }) {
17608
- const [request, setRequest] = useState(null);
17609
- const value = useMemo(() => ({
17610
- request,
17611
- play: (req) => setRequest(req),
17612
- clear: () => setRequest(null)
17613
- }), [request]);
17614
- return /* @__PURE__ */ jsx(VodPlaybackContext.Provider, {
17615
- value,
17616
- children
17681
+ var Badge = forwardRef(({ className, variant, style, ...props }, ref) => {
17682
+ return /* @__PURE__ */ jsx("span", {
17683
+ ref,
17684
+ className: cn(badgeVariants({
17685
+ variant,
17686
+ styleVariant: style
17687
+ }), className),
17688
+ ...props
17617
17689
  });
17618
- }
17619
- /** @returns the VOD playback controller (inert no-op outside a provider). */
17620
- function useVodPlayback() {
17621
- return useContext(VodPlaybackContext);
17622
- }
17690
+ });
17691
+ Badge.displayName = "Badge";
17623
17692
  //#endregion
17624
- //#region src/composites/clips/ClipPanel.tsx
17693
+ //#region src/primitives/button.tsx
17694
+ var buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
17695
+ variants: {
17696
+ variant: {
17697
+ primary: "bg-primary text-primary-foreground hover:bg-primary/90",
17698
+ secondary: "bg-surface text-foreground hover:bg-surface-hover",
17699
+ ghost: "hover:bg-surface-hover text-foreground-muted",
17700
+ danger: "bg-danger text-white hover:bg-danger/90",
17701
+ outline: "border border-border bg-transparent text-foreground hover:bg-surface-hover"
17702
+ },
17703
+ size: {
17704
+ sm: "h-7 px-2.5 text-xs gap-1.5",
17705
+ md: "h-8 px-3 text-sm gap-2",
17706
+ lg: "h-9 px-4 text-sm gap-2"
17707
+ }
17708
+ },
17709
+ defaultVariants: {
17710
+ variant: "primary",
17711
+ size: "sm"
17712
+ }
17713
+ });
17714
+ var Button = forwardRef(({ className, variant, size, ...props }, ref) => {
17715
+ return /* @__PURE__ */ jsx("button", {
17716
+ ref,
17717
+ className: cn(buttonVariants({
17718
+ variant,
17719
+ size
17720
+ }), className),
17721
+ ...props
17722
+ });
17723
+ });
17724
+ Button.displayName = "Button";
17725
+ //#endregion
17726
+ //#region src/primitives/select.tsx
17625
17727
  /**
17626
- * The selected clip — what it is, how it plays, and how it is saved.
17627
- *
17628
- * **No second player.** `getClipPlayback` answers `{ playbackUrl, format,
17629
- * served }` and the panel hands it to the SAME hero the recordings timeline
17630
- * drives: `vod.play({ url, format })` → `StreamPanel playbackUrl/playbackFormat`
17631
- * → `HlsVideo`'s `mp4` branch (`video.src`, no hls.js). Building a clip player
17632
- * here is the thing D549 decision 22 exists to refuse.
17633
- *
17634
- * **`format` travels.** An MP4 handed to hls.js is parsed as a manifest and
17635
- * fails, so the format the provider named is forwarded verbatim and never
17636
- * defaulted here.
17728
+ * A native `<select>` styled to match the rest of the field set.
17637
17729
  *
17638
- * **`served` is shown.** A clip that holds only one twin answers with the one
17639
- * it has, and the surface SAYS so — a missing main twin must never be served
17640
- * silently as if it were the asked-for quality (D549 decision 15). Both ids
17641
- * are already on the row, so changing quality re-asks the hub and never
17642
- * re-searches the camera.
17730
+ * `appearance-none` plus an explicit `bg-surface` / `text-foreground` is what
17731
+ * makes it match — and it is also what USED to make a disabled one a lie. A
17732
+ * browser greys a disabled control by rendering its own background and text
17733
+ * colour; all three of those are overridden here, so a `disabled` dropdown
17734
+ * looked exactly like a live one and simply ignored the click. That is the
17735
+ * whole of the "the language selector does not open" report of 2026-08-15:
17736
+ * the AI section's switch was off, every control under it was correctly inert,
17737
+ * and nothing on screen said so.
17643
17738
  *
17644
- * **Nothing is appended to the URL.** The route is `access:'authenticated'`
17645
- * and the session cookie authenticates it (D549 decision 22); the same URL is
17646
- * the download.
17739
+ * `disabled:` classes belong HERE and not on each call site: there are dozens
17740
+ * of `<Select disabled>` in this app and the one that gets forgotten is the one
17741
+ * that generates the ticket. Guarded by `disabled-affordance.test.tsx`.
17647
17742
  */
17648
- var PROFILES = [
17649
- "low",
17650
- "mid",
17651
- "high"
17652
- ];
17653
- function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
17654
- const vod = useVodPlayback();
17655
- const playback = useVideoclipsGetClipPlayback({
17656
- deviceId,
17657
- clipId: clip.id,
17658
- profile
17659
- });
17660
- const data = playback.data;
17661
- const label = `${new Date(clip.timeRange.startMs).toLocaleString()} · ${clip.source}`;
17662
- useEffect(() => {
17663
- if (data === void 0) return;
17664
- vod.play({
17665
- url: hubPath(data.playbackUrl),
17666
- ...data.format === void 0 ? {} : { format: data.format },
17667
- label
17668
- });
17669
- }, [data, label]);
17743
+ var Select = forwardRef(({ className, options, disabled, ...props }, ref) => {
17670
17744
  return /* @__PURE__ */ jsxs("div", {
17671
- className: "space-y-2 rounded-md border border-border p-3 text-sm",
17672
- children: [
17673
- /* @__PURE__ */ jsx("div", {
17674
- className: "font-medium",
17675
- children: label
17676
- }),
17677
- /* @__PURE__ */ jsxs("div", {
17678
- className: "flex items-center gap-2",
17679
- children: [/* @__PURE__ */ jsx("span", {
17680
- className: "text-xs text-muted-foreground",
17681
- children: "Quality"
17682
- }), PROFILES.map((option) => /* @__PURE__ */ jsx("button", {
17683
- type: "button",
17684
- "aria-pressed": option === profile,
17685
- onClick: () => onProfileChange(option),
17686
- className: `rounded px-2 py-0.5 text-xs ${option === profile ? "bg-primary text-primary-foreground" : "bg-muted"}`,
17687
- children: option
17688
- }, option))]
17689
- }),
17690
- playback.status === "pending" ? /* @__PURE__ */ jsx("div", {
17691
- role: "status",
17692
- children: "Preparing the clip — the file is fetched and muxed once…"
17693
- }) : playback.status === "error" ? /* @__PURE__ */ jsxs("div", {
17694
- role: "alert",
17695
- children: ["This clip could not be prepared: ", playback.error?.message ?? "no reason given"]
17696
- }) : data === void 0 ? null : /* @__PURE__ */ jsxs("div", {
17697
- className: "space-y-1",
17698
- children: [data.served === void 0 || data.served === profile ? null : /* @__PURE__ */ jsxs("div", {
17699
- className: "text-xs text-amber-600 dark:text-amber-400",
17700
- children: [
17701
- "Served the ",
17702
- /* @__PURE__ */ jsx("strong", { children: data.served }),
17703
- " stream — this clip has no ",
17704
- profile,
17705
- " twin."
17706
- ]
17707
- }), /* @__PURE__ */ jsx("a", {
17708
- href: hubPath(data.playbackUrl),
17709
- download: true,
17710
- className: "text-xs underline",
17711
- children: "Download this clip"
17712
- })]
17713
- })
17714
- ]
17715
- });
17716
- }
17717
- //#endregion
17718
- //#region src/composites/clips/ClipSilentSources.tsx
17719
- function ClipSilentSources({ silent }) {
17720
- if (silent.length === 0) return null;
17721
- return /* @__PURE__ */ jsx("ul", {
17722
- className: "space-y-1 rounded-md border border-amber-500/40 bg-amber-500/5 p-2 text-xs",
17723
- children: silent.map((notice) => /* @__PURE__ */ jsxs("li", { children: [
17724
- /* @__PURE__ */ jsx("span", {
17725
- className: "font-medium",
17726
- children: notice.label
17727
- }),
17728
- /* @__PURE__ */ jsxs("span", {
17729
- className: "text-muted-foreground",
17730
- children: [
17731
- " (",
17732
- notice.source,
17733
- ")"
17734
- ]
17735
- }),
17736
- /* @__PURE__ */ jsx("div", { children: notice.headline }),
17737
- notice.detail === null ? null : /* @__PURE__ */ jsx("div", {
17738
- className: "text-muted-foreground break-words",
17739
- children: notice.detail
17740
- })
17741
- ] }, notice.source))
17745
+ className: "relative w-full",
17746
+ children: [/* @__PURE__ */ jsx("select", {
17747
+ ref,
17748
+ disabled,
17749
+ className: cn("h-7 w-full px-2.5 text-xs bg-surface border border-border rounded-md text-foreground appearance-none pr-7 focus:outline-none focus:ring-1 focus:ring-primary", "disabled:opacity-50 disabled:cursor-not-allowed", className),
17750
+ ...props,
17751
+ children: options.map((option) => /* @__PURE__ */ jsx("option", {
17752
+ value: option.value,
17753
+ children: option.label
17754
+ }, option.value))
17755
+ }), /* @__PURE__ */ jsx("div", {
17756
+ className: cn("absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-foreground-muted", disabled === true ? "opacity-50" : ""),
17757
+ children: /* @__PURE__ */ jsx(ChevronDown, { className: "h-3.5 w-3.5" })
17758
+ })]
17742
17759
  });
17743
- }
17760
+ });
17761
+ Select.displayName = "Select";
17744
17762
  //#endregion
17745
- //#region src/composites/clips/ClipSourceRow.tsx
17746
- var TONE_CLASS = {
17747
- ok: "text-emerald-600 dark:text-emerald-400",
17748
- warn: "text-amber-600 dark:text-amber-400",
17749
- error: "text-rose-600 dark:text-rose-400"
17750
- };
17751
- function ClipSourceRow({ notice, catalogAge, checked, onToggle }) {
17752
- return /* @__PURE__ */ jsxs("li", {
17753
- className: "flex items-start gap-2 py-1.5",
17754
- children: [/* @__PURE__ */ jsx("input", {
17755
- type: "checkbox",
17756
- className: "mt-1",
17757
- checked,
17758
- "aria-label": `Show ${notice.label}`,
17759
- onChange: (e) => onToggle(notice.source, e.target.checked)
17760
- }), /* @__PURE__ */ jsxs("div", {
17761
- className: "min-w-0",
17762
- children: [
17763
- /* @__PURE__ */ jsxs("div", {
17764
- className: "flex flex-wrap items-baseline gap-x-2",
17765
- children: [
17766
- /* @__PURE__ */ jsx("span", {
17767
- className: "font-medium",
17768
- children: notice.label
17769
- }),
17770
- /* @__PURE__ */ jsx("span", {
17771
- className: "text-xs text-muted-foreground",
17772
- children: notice.source
17773
- }),
17774
- catalogAge === null ? null : /* @__PURE__ */ jsxs("span", {
17775
- className: "text-xs text-muted-foreground",
17776
- children: ["read ", catalogAge]
17777
- })
17778
- ]
17779
- }),
17780
- /* @__PURE__ */ jsx("div", {
17781
- className: `text-xs ${TONE_CLASS[notice.tone]}`,
17782
- children: notice.headline
17783
- }),
17784
- notice.detail === null ? null : /* @__PURE__ */ jsx("div", {
17785
- className: "text-xs text-muted-foreground break-words",
17786
- children: notice.detail
17787
- })
17788
- ]
17789
- })]
17763
+ //#region src/primitives/skeleton.tsx
17764
+ var Skeleton = forwardRef(({ className, ...props }, ref) => {
17765
+ return /* @__PURE__ */ jsx("div", {
17766
+ ref,
17767
+ className: cn("animate-pulse bg-surface-hover rounded-md h-4 w-full", className),
17768
+ ...props
17790
17769
  });
17791
- }
17770
+ });
17771
+ Skeleton.displayName = "Skeleton";
17792
17772
  //#endregion
17793
- //#region src/composites/clips/clip-tile-media.ts
17773
+ //#region src/hooks/use-is-mobile.ts
17774
+ var MOBILE_QUERY = "(max-width: 767px)";
17794
17775
  /**
17795
- * Why no still exists, in words. Keyed by the provider's own vocabulary so a
17796
- * reason this table does not know still reaches the operator verbatim.
17776
+ * Mid breakpoint — below this width the sidebar collapses to an
17777
+ * icons-only rail to reclaim canvas space. Above it (and outside
17778
+ * mobile) the full sidebar renders. Threshold matches Tailwind `lg`
17779
+ * so sidebar fans out at the same point grids switch to multi-column.
17797
17780
  */
17798
- var REFUSAL_TEXT = {
17799
- sleeping: "The camera is asleep — a still would cost a wake",
17800
- "camera-refused": "The camera refused the still request",
17801
- "no-keyframe": "No decodable frame inside this window",
17802
- unsupported: "This source cannot produce stills",
17803
- "queue-full": "The camera is busy — its still queue is full",
17804
- "no-catalog-row": "This clip is no longer in the catalog"
17805
- };
17806
- function refusal(reason) {
17807
- return {
17808
- kind: "unavailable",
17809
- reason,
17810
- text: REFUSAL_TEXT[reason] ?? `Refused: ${reason}`
17811
- };
17781
+ var MID_QUERY = "(min-width: 768px) and (max-width: 1023px)";
17782
+ /**
17783
+ * `matchMedia` is universal in browsers but absent in bare DOM environments.
17784
+ * These hooks are called from shared primitives (`DataTable` picks its narrow
17785
+ * layout with one), so throwing here takes down the whole page that merely
17786
+ * rendered a table. Where the capability is missing, report the desktop
17787
+ * layout — a widescreen rendering is wrong-looking; a crashed page is gone.
17788
+ */
17789
+ function matchQuery(query) {
17790
+ if (typeof window.matchMedia !== "function") return null;
17791
+ return window.matchMedia(query);
17812
17792
  }
17813
- /** What to draw for one clip's tile. */
17814
- function resolveTileMedia(clip) {
17815
- if (clip.thumbnail !== void 0) return {
17816
- kind: "vouched",
17817
- url: clip.thumbnail
17818
- };
17819
- if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason);
17820
- if (clip.thumbnailMint !== void 0) return {
17821
- kind: "mint",
17822
- url: clip.thumbnailMint
17823
- };
17824
- return {
17825
- kind: "none",
17826
- text: "This clip carries no still and offered none"
17793
+ function subscribeQuery(query) {
17794
+ return (callback) => {
17795
+ const mql = matchQuery(query);
17796
+ if (mql === null) return () => {};
17797
+ mql.addEventListener("change", callback);
17798
+ return () => mql.removeEventListener("change", callback);
17827
17799
  };
17828
17800
  }
17801
+ function getSnapshot(query) {
17802
+ return () => matchQuery(query)?.matches ?? false;
17803
+ }
17804
+ function getServerSnapshot() {
17805
+ return false;
17806
+ }
17807
+ function useIsMobile() {
17808
+ return useSyncExternalStore(subscribeQuery(MOBILE_QUERY), getSnapshot(MOBILE_QUERY), getServerSnapshot);
17809
+ }
17829
17810
  /**
17830
- * A mint that answered 204 — latch its `x-camstack-reason` to this instant.
17831
- *
17832
- * The refusal is about NOW (a full queue, a sleeping camera), so it replaces
17833
- * the offer for as long as the tile is mounted and is not retried behind the
17834
- * operator's back. An unknown reason is shown raw rather than dropped: a
17835
- * firmware inventing a refusal must not become a blank tile.
17811
+ * True when the viewport is in the medium (`md`) Tailwind range —
17812
+ * 768px ≤ width < 1024px. Use to drive layouts that should pack denser
17813
+ * than full-desktop but more legibly than mobile (e.g. icons-only
17814
+ * sidebar rail, narrower stat tiles).
17836
17815
  */
17837
- function latchMintRefusal(reason) {
17838
- return refusal(reason);
17816
+ function useIsMidWidth() {
17817
+ return useSyncExternalStore(subscribeQuery(MID_QUERY), getSnapshot(MID_QUERY), getServerSnapshot);
17839
17818
  }
17840
17819
  //#endregion
17841
- //#region src/composites/clips/clip-thumbnail-fetch.ts
17842
- /**
17843
- * Asking a `thumbnailMint` route for a still — and hearing its refusal.
17844
- *
17845
- * The route answers **200** with a JPEG or **204** with the reason in
17846
- * `x-camstack-reason` (`queue-full`, `sleeping`, `camera-refused`,
17847
- * `no-keyframe`, `no-catalog-row`). An `<img src>` cannot read that header:
17848
- * a 204 fires `onerror` with nothing to say, turning a named refusal into an
17849
- * anonymous broken tile. So a mint goes through `fetch`.
17850
- *
17851
- * **Nothing is appended to the URL.** Both data-plane routes are registered
17852
- * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
17853
- * the session cookie the admin UI sets at login, so the URL is never a
17854
- * credential (D549 decision 22) — it may sit in history or a `Referer` and
17855
- * open nothing.
17856
- *
17857
- * The refusal is about NOW and is LATCHED by the caller: a camera that just
17858
- * said "busy" is not asked again behind the operator's back.
17859
- */
17820
+ //#region src/composites/data-table-layout.ts
17860
17821
  /**
17861
- * Ask a mint route for one still.
17862
- *
17863
- * Never rejects: a thrown fetch is a refusal like any other, because a tile
17864
- * that throws is a tile that renders nothing and says nothing — the exact
17865
- * failure this module exists to prevent.
17822
+ * At this many columns and above, `auto` switches a narrow viewport to cards.
17823
+ * Three columns still fit a phone at a readable size; four do not.
17866
17824
  */
17867
- async function fetchMintedThumbnail(url, fetcher) {
17868
- let response;
17869
- try {
17870
- response = await fetcher(url, { credentials: "same-origin" });
17871
- } catch (err) {
17872
- return latchMintRefusal(err instanceof Error ? err.message : String(err));
17873
- }
17874
- if (response.status === 204) return latchMintRefusal(response.headers.get("x-camstack-reason") ?? "no reason given");
17875
- if (!response.ok) return latchMintRefusal(`HTTP ${String(response.status)}`);
17876
- try {
17877
- return {
17878
- kind: "image",
17879
- blob: await response.blob()
17880
- };
17881
- } catch (err) {
17882
- return latchMintRefusal(err instanceof Error ? err.message : String(err));
17883
- }
17825
+ var CARD_MODE_MIN_COLUMNS = 4;
17826
+ function resolveTableLayout({ mode, columnCount, isNarrow }) {
17827
+ if (!isNarrow) return "table";
17828
+ if (mode === "scroll") return "table";
17829
+ if (mode === "cards") return "cards";
17830
+ if (columnCount === 0) return "table";
17831
+ return columnCount >= 4 ? "cards" : "table";
17884
17832
  }
17885
17833
  //#endregion
17886
- //#region src/composites/clips/ClipTile.tsx
17834
+ //#region src/lib/responsive.ts
17887
17835
  /**
17888
- * One clip, as a tile.
17836
+ * Responsive design tokens — three-tier system.
17889
17837
  *
17890
- * The picture is resolved by `resolveTileMedia` — vouched JPEG, an offer, or
17891
- * the reason there is none (D549 decision 5). An offer is fetched rather than
17892
- * dropped into an `<img src>`, because the route's **204 + x-camstack-reason**
17893
- * is invisible to an `<img>`: it fires `onerror` with nothing to say. The
17894
- * refusal is LATCHED for the life of the tile and never retried behind the
17895
- * operator's back.
17838
+ * The admin-ui targets three width tiers; every component should pick
17839
+ * the right token from this file instead of hand-coding breakpoints.
17840
+ * Adopting the tokens uniformly is what makes the app feel consistent
17841
+ * across operator devices (mobile / tablet / desktop) and what unlocks
17842
+ * targeted tuning (e.g. shrink every label by 1px in one place).
17896
17843
  *
17897
- * The row also carries what the camera itself said: its native types, the
17898
- * behaviours beside the class, which storages hold it, and — for a row that
17899
- * is an EVENT sitting inside no file — `playable: false` with its reason
17900
- * (measured: 1 of 29 hub events on 3628). Such a row is SHOWN, never dropped
17901
- * and never offered as playable-then-failing (D549 decision 19).
17902
- */
17903
- function clockOf(ms) {
17904
- return new Date(ms).toLocaleTimeString(void 0, {
17905
- hour: "2-digit",
17906
- minute: "2-digit",
17907
- second: "2-digit"
17908
- });
17909
- }
17910
- /**
17911
- * Ask a mint route once, and LATCH whatever it says.
17844
+ * Tier definitions (Tailwind breakpoints):
17912
17845
  *
17913
- * The offer is memoised on the three fields it is derived from, not rebuilt
17914
- * per render: `resolveTileMedia` returns a fresh object every call, and an
17915
- * effect keyed on that object re-runs on every commit — which, because the
17916
- * effect sets state, is an infinite render loop. It was one here, and it hung
17917
- * the suite rather than failing it.
17846
+ * • **L1 — Compact** (`< 640px`, default = mobile portrait)
17847
+ * Single column, tight padding, smallest text, icon-only buttons
17848
+ * where labels can be inferred. The operator can still see every
17849
+ * stat / action — no horizontal scroll, no clipped text. The user
17850
+ * directive: "molta informazione in poco spazio quando la width è
17851
+ * bassa."
17918
17852
  *
17919
- * The refusal is latched for the life of the tile: a camera that just said
17920
- * "busy" is not asked again behind the operator's back (D549 decision 5).
17853
+ * • **L2 — Standard** (`sm` — `640–1023px`, tablet + small laptop)
17854
+ * 2-column grids, side panels become visible, button labels return.
17855
+ * Targets ~768px–1024px viewports — the most common operator sizes.
17856
+ *
17857
+ * • **L3 — Spacious** (`lg+` — `≥ 1024px`, desktop)
17858
+ * 4-column grids, generous padding, full-width header text,
17859
+ * side-by-side composite layouts. The operator has elbow room.
17860
+ *
17861
+ * Conventions:
17862
+ * - **Always start from L1** — the bare class is always the compact
17863
+ * variant, then `sm:` adds room at 640px, `lg:` at 1024px. No
17864
+ * desktop-first breakpoints; no max-width queries.
17865
+ * - **Token strings are static** — kept as `as const` so callers can
17866
+ * compose them via `cn()` / template literals without running a
17867
+ * class generator at runtime.
17868
+ * - **Pair the token with a comment** — the comment in the file
17869
+ * names every breakpoint it crosses, so a reader can predict the
17870
+ * layout without going to Tailwind docs.
17921
17871
  */
17922
- function useMintedStill(clip) {
17923
- const offered = useMemo(() => resolveTileMedia(clip), [
17924
- clip.thumbnail,
17925
- clip.thumbnailMint,
17926
- clip.thumbnailUnavailable?.reason
17927
- ]);
17928
- const [latched, setLatched] = useState(null);
17929
- const mintUrl = offered.kind === "mint" ? offered.url : null;
17930
- useEffect(() => {
17931
- setLatched(null);
17932
- if (mintUrl === null || typeof fetch !== "function") return void 0;
17933
- let objectUrl = null;
17934
- let cancelled = false;
17935
- (async () => {
17936
- const answer = await fetchMintedThumbnail(hubPath(mintUrl), fetch);
17937
- if (cancelled) return;
17872
+ /** Container padding for a card-style section (header + body). */
17873
+ var SECTION_CARD = "rounded-lg border border-border bg-surface overflow-hidden";
17874
+ /** Header strip inside a card (between border-b lines). */
17875
+ var SECTION_HEADER = "border-b border-border px-3 sm:px-4 py-1.5 sm:py-2";
17876
+ /** Body padding for a card-style section. */
17877
+ var SECTION_BODY = "p-2 sm:p-3 lg:p-4";
17878
+ /** Gap between cards in a vertical stack. */
17879
+ var STACK_GAP = "space-y-3 sm:space-y-4";
17880
+ /** Gap between cards in a grid (4 cells / row at lg). */
17881
+ var GRID_GAP = "gap-2 sm:gap-3";
17882
+ /** Row padding inside a divided list (e.g. InfoRow). */
17883
+ var LIST_ROW = "px-3 sm:px-4 py-1.5 sm:py-2";
17884
+ /** 1-up at L1, 2-up at L2, 4-up at L3 — quick-stat row. */
17885
+ var GRID_QUICK_STATS = "grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4";
17886
+ /** 1-up at L1, 2-up at L2+ — paired panels (motion + detection rules). */
17887
+ var GRID_PAIRED = "grid grid-cols-1 lg:grid-cols-2";
17888
+ /** Side-by-side layout that stacks below md (canvas + side panel). */
17889
+ var SPLIT_PANEL_OUTER = "flex flex-col-reverse md:flex-row";
17890
+ /** Sidebar narrow lane in a SPLIT_PANEL_OUTER layout. */
17891
+ var SPLIT_PANEL_SIDE = "w-full md:w-44 lg:w-52 md:flex-shrink-0 border-b md:border-b-0 md:border-l border-border";
17892
+ /**
17893
+ * The tier boundary at which a settings label/value row stops stacking.
17894
+ *
17895
+ * Below it (L1 — compact) the row is ONE column: label on its own line, value
17896
+ * on the next at full width. Above it the row is label-left / value-right as
17897
+ * before. The two-column form needs `SETTING_ROW_LABEL`'s lane plus a readable
17898
+ * value beside it; a phone has neither, and the failure mode is not "cramped"
17899
+ * but "the value is the part that gets cut" — the one thing the operator
17900
+ * opened the page to read.
17901
+ */
17902
+ var SETTING_ROW_STACK_BREAKPOINT = "sm";
17903
+ /** Outer row: single column at L1, label/value columns from `sm` up. */
17904
+ var SETTING_ROW = "flex min-w-0 flex-col gap-0.5 py-1.5 sm:flex-row sm:items-center";
17905
+ /** Label lane. Full width (and free to wrap) at L1; a fixed lane from `sm` up. */
17906
+ var SETTING_ROW_LABEL = "min-w-0 text-[11px] leading-tight text-foreground-subtle sm:w-32 sm:shrink-0 sm:pr-2";
17907
+ /**
17908
+ * Value lane — the value text plus its trailing affordances (copy / reveal).
17909
+ * `w-full` at L1 so the value owns the whole row once the label is above it;
17910
+ * `sm:flex-1` makes it share the row again from `sm` up.
17911
+ */
17912
+ var SETTING_ROW_VALUE = "flex w-full min-w-0 items-center gap-1 sm:flex-1";
17913
+ /**
17914
+ * The value text itself. `break-all` lets an opaque token (a key, a URL) wrap
17915
+ * at L1 instead of being cut mid-word; `sm:truncate` restores the single-line
17916
+ * desktop form, where the row is wide enough for truncation to be a choice
17917
+ * rather than data loss.
17918
+ */
17919
+ var SETTING_ROW_VALUE_TEXT = "min-w-0 break-all text-xs text-foreground sm:truncate";
17920
+ /** Section header label (uppercase tracking-wider). */
17921
+ var TEXT_SECTION_LABEL = "text-[10px] sm:text-[11px] font-semibold text-foreground uppercase tracking-wider";
17922
+ /** Field label inside a row. */
17923
+ var TEXT_FIELD_LABEL = "text-[11px] sm:text-xs text-foreground";
17924
+ /** Sub-label / hint text. */
17925
+ var TEXT_HINT = "text-[10px] text-foreground-subtle";
17926
+ /** Primary value (a metric, badge, …). */
17927
+ var TEXT_VALUE = "text-[11px] sm:text-xs font-medium text-foreground";
17928
+ /** Big metric value (StatCard). */
17929
+ var TEXT_METRIC = "text-base sm:text-lg font-semibold leading-tight text-foreground";
17930
+ /** Default compact button. */
17931
+ var BTN_COMPACT = "inline-flex items-center justify-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-foreground hover:bg-surface-hover transition-colors disabled:opacity-40";
17932
+ /** Primary compact button. */
17933
+ var BTN_COMPACT_PRIMARY = "inline-flex items-center justify-center gap-1 rounded border border-primary/40 bg-primary/15 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-primary hover:bg-primary/25 transition-colors disabled:opacity-40";
17934
+ /** Danger compact button. */
17935
+ var BTN_COMPACT_DANGER = "inline-flex items-center justify-center gap-1 rounded border border-danger/40 bg-danger/10 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-danger hover:bg-danger/20 transition-colors disabled:opacity-40";
17936
+ /** Warning compact button (e.g. cancel-drawing). */
17937
+ var BTN_COMPACT_WARNING = "inline-flex items-center justify-center gap-1 rounded border border-warning/40 bg-warning/10 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-warning hover:bg-warning/20 transition-colors disabled:opacity-40";
17938
+ /** Compact text/select/number input. */
17939
+ var INPUT_COMPACT = "w-full rounded border border-border bg-background px-1.5 py-0.5 sm:px-2 sm:py-1 text-[11px] sm:text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none";
17940
+ /** Chip-style toggle (zone / class selectors in ZoneRulesEditor). */
17941
+ var CHIP_BASE = "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors";
17942
+ var CHIP_ACTIVE = "border-primary/50 bg-primary/15 text-primary";
17943
+ var CHIP_INACTIVE = "border-border bg-surface text-foreground-subtle hover:bg-surface-hover";
17944
+ //#endregion
17945
+ //#region src/composites/setting-row.tsx
17946
+ function SettingRow({ label, children, actions, elements = "plain", className, valueClassName }) {
17947
+ const LabelTag = elements === "description" ? "dt" : "span";
17948
+ const ValueTag = elements === "description" ? "dd" : "div";
17949
+ return /* @__PURE__ */ jsxs("div", {
17950
+ "data-setting-row": "",
17951
+ className: cn(SETTING_ROW, className),
17952
+ children: [/* @__PURE__ */ jsx(LabelTag, {
17953
+ "data-setting-row-label": "",
17954
+ className: SETTING_ROW_LABEL,
17955
+ children: label
17956
+ }), /* @__PURE__ */ jsxs(ValueTag, {
17957
+ "data-setting-row-value": "",
17958
+ className: SETTING_ROW_VALUE,
17959
+ children: [/* @__PURE__ */ jsx("span", {
17960
+ "data-setting-row-value-text": "",
17961
+ className: cn(SETTING_ROW_VALUE_TEXT, valueClassName),
17962
+ children
17963
+ }), actions !== void 0 && /* @__PURE__ */ jsx("span", {
17964
+ className: "flex shrink-0 items-center gap-0.5",
17965
+ children: actions
17966
+ })]
17967
+ })]
17968
+ });
17969
+ }
17970
+ //#endregion
17971
+ //#region src/composites/sort-header-button.tsx
17972
+ function SortHeaderButton({ label, active, dir, onSort }) {
17973
+ return /* @__PURE__ */ jsxs("button", {
17974
+ type: "button",
17975
+ onClick: onSort,
17976
+ className: "group/sort inline-flex items-center gap-0.5 hover:text-foreground transition-colors",
17977
+ children: [/* @__PURE__ */ jsx("span", { children: label }), active ? dir === "asc" ? /* @__PURE__ */ jsx(ChevronUp, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ jsx(ChevronUp, { className: cn("h-3 w-3 flex-shrink-0 opacity-0 transition-opacity", "group-hover/sort:opacity-30") })]
17978
+ });
17979
+ }
17980
+ //#endregion
17981
+ //#region src/composites/table-sort.ts
17982
+ /**
17983
+ * The toggle rule, and the only copy of it.
17984
+ *
17985
+ * none → asc → desc → none, and a click on a DIFFERENT column starts that
17986
+ * column fresh at `asc`. `sameColumn` says whether the clicked column is the
17987
+ * one currently sorted; `currentDir` is its direction (`null` when nothing is
17988
+ * sorted). Returns the next direction, or `null` for "unsorted".
17989
+ *
17990
+ * Split out from {@link nextTableSort} so the device list — whose sort state
17991
+ * keys columns by `columnId`, not `columnKey` — can reuse the rule without a
17992
+ * cast and without a second copy of it.
17993
+ */
17994
+ function nextSortDirection(sameColumn, currentDir) {
17995
+ if (!sameColumn || currentDir === null) return "asc";
17996
+ return currentDir === "asc" ? "desc" : null;
17997
+ }
17998
+ /**
17999
+ * Toggle a column's sort on header click: none → asc → desc → none.
18000
+ * Clicking a DIFFERENT column starts it fresh at `asc`.
18001
+ */
18002
+ function nextTableSort(current, columnKey) {
18003
+ const dir = nextSortDirection(current !== null && current.columnKey === columnKey, current === null ? null : current.dir);
18004
+ return dir === null ? null : {
18005
+ columnKey,
18006
+ dir
18007
+ };
18008
+ }
18009
+ /**
18010
+ * Ascending comparison of two cell values.
18011
+ *
18012
+ * Numbers compare numerically and strings compare with `localeCompare` — a
18013
+ * numeric column must never be ordered as text ("100" before "9"), and a text
18014
+ * column must respect the locale's collation. A mixed column (one row a
18015
+ * number, another a string) falls back to string comparison of both, which is
18016
+ * the only total order available; it is not a shape any caller should build on
18017
+ * purpose.
18018
+ *
18019
+ * `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
18020
+ * because its placement is direction-independent and a comparator that is
18021
+ * multiplied by the direction factor cannot express that.
18022
+ */
18023
+ function compareValues(a, b) {
18024
+ if (typeof a === "number" && typeof b === "number") return a - b;
18025
+ return String(a).localeCompare(String(b));
18026
+ }
18027
+ /**
18028
+ * Return a NEW array of `rows` ordered by `sort`.
18029
+ *
18030
+ * `valueOf` is the column's `sortValue` — it is called once per row per
18031
+ * comparison, so it must be cheap (read a field, do not format a string).
18032
+ * `sort === null` ⇒ a fresh shallow copy in the incoming order.
18033
+ */
18034
+ function sortRowsByColumn(rows, sort, valueOf) {
18035
+ if (sort === null) return [...rows];
18036
+ const factor = sort.dir === "asc" ? 1 : -1;
18037
+ return rows.toSorted((a, b) => {
18038
+ const av = valueOf(a);
18039
+ const bv = valueOf(b);
18040
+ if (av === null || bv === null) {
18041
+ if (av === bv) return 0;
18042
+ return av === null ? 1 : -1;
18043
+ }
18044
+ return factor * compareValues(av, bv);
18045
+ });
18046
+ }
18047
+ /** Derive the `<th>` `aria-sort` for `columnKey` from the table's sort state. */
18048
+ function ariaSortForColumn(sort, columnKey) {
18049
+ if (sort === null || sort.columnKey !== columnKey) return "none";
18050
+ return sort.dir === "asc" ? "ascending" : "descending";
18051
+ }
18052
+ //#endregion
18053
+ //#region src/composites/data-table.tsx
18054
+ /**
18055
+ * DataTable — shared responsive table for admin pages.
18056
+ *
18057
+ * Recipes baked in:
18058
+ * - `overflow-x-auto` wrapper so narrow viewports don't clip the
18059
+ * right edge (the rounded border survives the scroll).
18060
+ * - `min-w-[Npx]` on the inner table so columns don't squash below
18061
+ * readable width before the scroll kicks in.
18062
+ * - `whitespace-nowrap` on headers — column titles never wrap to
18063
+ * two lines (`AUTO-KILL` was the regression that triggered this).
18064
+ * - Themed `bg-surface` + `border-border` so contrast works in both
18065
+ * light and dark themes.
18066
+ *
18067
+ * Column model: caller supplies `columns: DataColumn<T>[]` and
18068
+ * `rows: T[]`. Each column declares `header`, optional `align`,
18069
+ * optional `nowrap`, and a `render(row) => ReactNode` that produces
18070
+ * the cell content.
18071
+ *
18072
+ * <DataTable
18073
+ * columns={[
18074
+ * { key: 'username', header: 'Username', render: (u) => u.username },
18075
+ * { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
18076
+ * { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
18077
+ * ]}
18078
+ * rows={users}
18079
+ * minWidthPx={560}
18080
+ * />
18081
+ *
18082
+ * If finer header markup is needed, pass `headerRender` instead of
18083
+ * `header` (e.g. for a sortable click affordance).
18084
+ *
18085
+ * ── Narrow viewports ──────────────────────────────────────────────────────
18086
+ * The table stays a table. The wrapper is `overflow-x-auto`, so a phone
18087
+ * scrolls the columns sideways INSIDE the table — the page body never moves
18088
+ * sideways, and the cells stay one line (`whitespace-nowrap`). That is the
18089
+ * default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
18090
+ * `auto` still applies the old column-count rule for a surface that wants it.
18091
+ * See `resolveTableLayout`.
18092
+ *
18093
+ * ── Scrolling the BODY, not the page ──────────────────────────────────────
18094
+ * `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
18095
+ * with the column header pinned. It is ONE prop, not two, because "this list
18096
+ * scrolls internally" and "its header stays visible" are the same decision: a
18097
+ * capped table whose header scrolls away is a list of unlabelled cells.
18098
+ *
18099
+ * Use it wherever the row count is open-ended (rules, targets, delivery
18100
+ * history) so the page keeps its own header, filters and actions in view while
18101
+ * the operator moves through the rows.
18102
+ */
18103
+ /**
18104
+ * The default cap for an internally-scrolling table.
18105
+ *
18106
+ * `60vh` leaves the page header, its filters and its actions on screen at
18107
+ * every viewport while still showing ~10 rows — and it is a fraction, so it
18108
+ * does not need a breakpoint of its own.
18109
+ */
18110
+ var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
18111
+ var ALIGN_CLASS = {
18112
+ left: "text-left",
18113
+ right: "text-right",
18114
+ center: "text-center"
18115
+ };
18116
+ function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "scroll", maxBodyHeight, defaultSort }) {
18117
+ const isNarrow = useIsMobile();
18118
+ const [sort, setSort] = useState(defaultSort ?? null);
18119
+ const layout = resolveTableLayout({
18120
+ mode: mobileMode,
18121
+ columnCount: columns.length,
18122
+ isNarrow
18123
+ });
18124
+ const scrolls = maxBodyHeight !== void 0;
18125
+ const sortedColumn = useMemo(() => sort === null ? void 0 : columns.find((col) => col.key === sort.columnKey && col.sortValue !== void 0), [columns, sort]);
18126
+ const sortValue = sortedColumn?.sortValue;
18127
+ const displayRows = useMemo(() => sortValue === void 0 ? rows : sortRowsByColumn(rows, sort, sortValue), [
18128
+ rows,
18129
+ sort,
18130
+ sortValue
18131
+ ]);
18132
+ const activeSort = sortedColumn === void 0 ? null : sort;
18133
+ if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ jsx("div", {
18134
+ className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
18135
+ children: emptyMessage
18136
+ });
18137
+ if (rows.length === 0) return null;
18138
+ if (layout === "cards") return /* @__PURE__ */ jsx("div", {
18139
+ className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
18140
+ children: displayRows.map((row, rowIndex) => /* @__PURE__ */ jsx(DataTableCard, {
18141
+ row,
18142
+ rowIndex,
18143
+ columns,
18144
+ bordered,
18145
+ onRowClick,
18146
+ rowClassName
18147
+ }, rowKey ? rowKey(row, rowIndex) : rowIndex))
18148
+ });
18149
+ return /* @__PURE__ */ jsx("div", {
18150
+ className: [
18151
+ "rounded-lg",
18152
+ bordered ? "border border-border" : "",
18153
+ "bg-surface overflow-x-auto",
18154
+ scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
18155
+ className ?? ""
18156
+ ].filter(Boolean).join(" "),
18157
+ children: /* @__PURE__ */ jsxs("table", {
18158
+ className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
18159
+ style: { minWidth: `${minWidthPx}px` },
18160
+ children: [/* @__PURE__ */ jsx("thead", {
18161
+ className: "bg-surface",
18162
+ children: /* @__PURE__ */ jsx("tr", { children: columns.map((col) => {
18163
+ const align = col.align ?? "left";
18164
+ const nowrap = col.nowrap ?? true;
18165
+ const sortable = col.sortValue !== void 0;
18166
+ const isSorted = activeSort !== null && activeSort.columnKey === col.key;
18167
+ return /* @__PURE__ */ jsx("th", {
18168
+ "aria-sort": sortable ? ariaSortForColumn(activeSort, col.key) : void 0,
18169
+ title: sortable ? void 0 : col.notSortableReason,
18170
+ className: [
18171
+ "px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
18172
+ scrolls ? "sticky top-0 z-10" : "",
18173
+ ALIGN_CLASS[align],
18174
+ nowrap ? "whitespace-nowrap" : "",
18175
+ col.width ?? ""
18176
+ ].filter(Boolean).join(" "),
18177
+ children: sortable ? /* @__PURE__ */ jsx(SortHeaderButton, {
18178
+ label: col.headerRender ? col.headerRender() : col.header,
18179
+ active: isSorted,
18180
+ dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
18181
+ onSort: () => setSort((prev) => nextTableSort(prev, col.key))
18182
+ }) : col.headerRender ? col.headerRender() : col.header
18183
+ }, col.key);
18184
+ }) })
18185
+ }), /* @__PURE__ */ jsx("tbody", { children: displayRows.map((row, rowIndex) => {
18186
+ const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
18187
+ const extra = rowClassName?.(row, rowIndex) ?? "";
18188
+ const interactive = onRowClick !== void 0;
18189
+ return /* @__PURE__ */ jsx("tr", {
18190
+ onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
18191
+ className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
18192
+ children: columns.map((col) => {
18193
+ const align = col.align ?? "left";
18194
+ const nowrap = col.nowrap ?? true;
18195
+ return /* @__PURE__ */ jsx("td", {
18196
+ className: [
18197
+ "px-3 py-2 text-foreground border-b border-border",
18198
+ ALIGN_CLASS[align],
18199
+ nowrap ? "whitespace-nowrap" : ""
18200
+ ].filter(Boolean).join(" "),
18201
+ children: col.render(row, rowIndex)
18202
+ }, col.key);
18203
+ })
18204
+ }, key);
18205
+ }) })]
18206
+ })
18207
+ });
18208
+ }
18209
+ /**
18210
+ * One row as a card. Columns that carry a plain-text `header` become labelled
18211
+ * `SettingRow`s — the label is the column title and the value gets the full
18212
+ * card width. Columns with no text header (an actions column, a
18213
+ * `headerRender`-only column) have no label to show, so they render as a
18214
+ * full-width strip at the foot of the card.
18215
+ */
18216
+ function DataTableCard({ row, rowIndex, columns, bordered, onRowClick, rowClassName }) {
18217
+ const labelled = columns.filter((col) => col.header !== void 0 && col.header !== "");
18218
+ const unlabelled = columns.filter((col) => col.header === void 0 || col.header === "");
18219
+ const interactive = onRowClick !== void 0;
18220
+ const extra = rowClassName?.(row, rowIndex) ?? "";
18221
+ return /* @__PURE__ */ jsxs("div", {
18222
+ "data-data-table-card": "",
18223
+ onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
18224
+ className: [
18225
+ "rounded-lg bg-surface px-3 py-1.5 divide-y divide-border-subtle",
18226
+ bordered ? "border border-border" : "",
18227
+ interactive ? "cursor-pointer hover:bg-primary/5" : "",
18228
+ extra
18229
+ ].filter(Boolean).join(" "),
18230
+ children: [labelled.map((col) => /* @__PURE__ */ jsx(SettingRow, {
18231
+ label: col.header,
18232
+ children: col.render(row, rowIndex)
18233
+ }, col.key)), unlabelled.length > 0 && /* @__PURE__ */ jsx("div", {
18234
+ className: "flex flex-wrap items-center justify-end gap-2 py-1.5",
18235
+ children: unlabelled.map((col) => /* @__PURE__ */ jsx("div", { children: col.render(row, rowIndex) }, col.key))
18236
+ })]
18237
+ });
18238
+ }
18239
+ //#endregion
18240
+ //#region src/composites/device-list/pagination.tsx
18241
+ var NEIGHBORS = 2;
18242
+ function computePageButtons(currentPage, totalPages) {
18243
+ if (totalPages <= 1) return [{
18244
+ kind: "page",
18245
+ n: 1
18246
+ }];
18247
+ if (totalPages <= 7) {
18248
+ const all = [];
18249
+ for (let i = 1; i <= totalPages; i++) all.push({
18250
+ kind: "page",
18251
+ n: i
18252
+ });
18253
+ return all;
18254
+ }
18255
+ const result = [];
18256
+ const leftBound = Math.max(2, currentPage - NEIGHBORS);
18257
+ const rightBound = Math.min(totalPages - 1, currentPage + NEIGHBORS);
18258
+ result.push({
18259
+ kind: "page",
18260
+ n: 1
18261
+ });
18262
+ if (leftBound > 2) result.push({
18263
+ kind: "ellipsis",
18264
+ side: "left"
18265
+ });
18266
+ for (let i = leftBound; i <= rightBound; i++) result.push({
18267
+ kind: "page",
18268
+ n: i
18269
+ });
18270
+ if (rightBound < totalPages - 1) result.push({
18271
+ kind: "ellipsis",
18272
+ side: "right"
18273
+ });
18274
+ result.push({
18275
+ kind: "page",
18276
+ n: totalPages
18277
+ });
18278
+ return result;
18279
+ }
18280
+ function Pagination({ currentPage, totalItems, pageSize, onPageChange, itemNoun = "devices" }) {
18281
+ if (totalItems <= pageSize) return null;
18282
+ const totalPages = Math.ceil(totalItems / pageSize);
18283
+ const startIdx = (currentPage - 1) * pageSize + 1;
18284
+ const endIdx = Math.min(currentPage * pageSize, totalItems);
18285
+ const buttons = computePageButtons(currentPage, totalPages);
18286
+ return /* @__PURE__ */ jsxs("div", {
18287
+ className: "flex items-center justify-between gap-4 flex-wrap pt-2 text-[11px] text-foreground-subtle",
18288
+ children: [/* @__PURE__ */ jsxs("span", { children: [
18289
+ "Showing ",
18290
+ startIdx,
18291
+ "–",
18292
+ endIdx,
18293
+ " of ",
18294
+ totalItems,
18295
+ " ",
18296
+ itemNoun
18297
+ ] }), /* @__PURE__ */ jsxs("span", {
18298
+ className: "inline-flex items-center gap-1",
18299
+ children: [
18300
+ /* @__PURE__ */ jsx("button", {
18301
+ type: "button",
18302
+ "aria-label": "Previous page",
18303
+ disabled: currentPage === 1,
18304
+ onClick: () => onPageChange(currentPage - 1),
18305
+ className: cn("inline-flex items-center justify-center h-7 w-7 rounded border border-border bg-surface text-foreground-subtle", "hover:text-foreground hover:bg-surface-hover transition-colors", "disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface"),
18306
+ children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-3 w-3" })
18307
+ }),
18308
+ buttons.map((b, i) => {
18309
+ if (b.kind === "ellipsis") return /* @__PURE__ */ jsx("span", {
18310
+ className: "px-1 text-foreground-subtle/60 select-none",
18311
+ children: "…"
18312
+ }, `e-${i}`);
18313
+ return /* @__PURE__ */ jsx("button", {
18314
+ type: "button",
18315
+ onClick: () => onPageChange(b.n),
18316
+ className: cn("inline-flex items-center justify-center h-7 min-w-7 px-1.5 rounded border text-[10.5px] font-medium transition-colors", b.n === currentPage ? "border-primary/40 bg-primary/10 text-primary" : "border-border bg-surface text-foreground-subtle hover:text-foreground hover:bg-surface-hover"),
18317
+ children: b.n
18318
+ }, `p-${b.n}`);
18319
+ }),
18320
+ /* @__PURE__ */ jsx("button", {
18321
+ type: "button",
18322
+ "aria-label": "Next page",
18323
+ disabled: currentPage === totalPages,
18324
+ onClick: () => onPageChange(currentPage + 1),
18325
+ className: cn("inline-flex items-center justify-center h-7 w-7 rounded border border-border bg-surface text-foreground-subtle", "hover:text-foreground hover:bg-surface-hover transition-colors", "disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface"),
18326
+ children: /* @__PURE__ */ jsx(ChevronRight, { className: "h-3 w-3" })
18327
+ })
18328
+ ]
18329
+ })]
18330
+ });
18331
+ }
18332
+ //#endregion
18333
+ //#region src/composites/empty-state.tsx
18334
+ function EmptyState({ icon: Icon, title, description, action, className }) {
18335
+ return /* @__PURE__ */ jsxs("div", {
18336
+ className: cn("flex flex-col items-center justify-center gap-3 py-12", className),
18337
+ children: [
18338
+ Icon && /* @__PURE__ */ jsx(Icon, {
18339
+ className: "h-12 w-12 text-foreground-subtle",
18340
+ "aria-hidden": "true"
18341
+ }),
18342
+ /* @__PURE__ */ jsxs("div", {
18343
+ className: "flex flex-col items-center gap-1 text-center",
18344
+ children: [/* @__PURE__ */ jsx("p", {
18345
+ className: "text-foreground-muted text-sm font-medium",
18346
+ children: title
18347
+ }), description && /* @__PURE__ */ jsx("p", {
18348
+ className: "text-foreground-subtle text-xs max-w-xs",
18349
+ children: description
18350
+ })]
18351
+ }),
18352
+ action && /* @__PURE__ */ jsx("div", {
18353
+ className: "mt-1",
18354
+ children: action
18355
+ })
18356
+ ]
18357
+ });
18358
+ }
18359
+ //#endregion
18360
+ //#region src/composites/error-box.tsx
18361
+ var VARIANT_STYLES$1 = {
18362
+ danger: {
18363
+ box: "border-danger/40 bg-danger/10",
18364
+ icon: "text-danger",
18365
+ title: "text-danger",
18366
+ message: "text-danger"
18367
+ },
18368
+ warning: {
18369
+ box: "border-amber-500/40 bg-amber-500/10",
18370
+ icon: "text-amber-600 dark:text-amber-400",
18371
+ title: "text-amber-700 dark:text-amber-300",
18372
+ message: "text-amber-700 dark:text-amber-300"
18373
+ }
18374
+ };
18375
+ function ErrorBox({ message, title, onRetry, action, variant = "danger", className }) {
18376
+ const styles = VARIANT_STYLES$1[variant];
18377
+ const Icon = variant === "warning" ? TriangleAlert : CircleAlert;
18378
+ return /* @__PURE__ */ jsxs("div", {
18379
+ role: "alert",
18380
+ className: cn("flex items-start gap-2.5 rounded border px-3 py-2.5", styles.box, className),
18381
+ children: [
18382
+ /* @__PURE__ */ jsx(Icon, {
18383
+ className: cn("h-4 w-4 mt-0.5 shrink-0", styles.icon),
18384
+ "aria-hidden": "true"
18385
+ }),
18386
+ /* @__PURE__ */ jsxs("div", {
18387
+ className: "flex-1 min-w-0",
18388
+ children: [title && /* @__PURE__ */ jsx("p", {
18389
+ className: cn("text-xs font-semibold", styles.title),
18390
+ children: title
18391
+ }), /* @__PURE__ */ jsx("div", {
18392
+ className: cn("text-xs break-words", styles.message, title ? "mt-0.5" : ""),
18393
+ children: message
18394
+ })]
18395
+ }),
18396
+ action ?? (onRetry && /* @__PURE__ */ jsxs("button", {
18397
+ type: "button",
18398
+ onClick: onRetry,
18399
+ className: cn("shrink-0 inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium", "border border-current/30 hover:bg-current/10 transition-colors", styles.message),
18400
+ children: [/* @__PURE__ */ jsx(RefreshCw, {
18401
+ className: "h-3 w-3",
18402
+ "aria-hidden": "true"
18403
+ }), "Retry"]
18404
+ }))
18405
+ ]
18406
+ });
18407
+ }
18408
+ //#endregion
18409
+ //#region src/contexts/vod-playback.tsx
18410
+ /**
18411
+ * VodPlayback — shared context coordinating recorded (VOD) playback between a
18412
+ * widget/section (the Recording panel) and the hero StreamPanel.
18413
+ *
18414
+ * A CONTROLLER (the Recording widget) calls `play({ url, label })`; the hero
18415
+ * StreamPanel CONSUMES `request` and switches into VOD mode (HLS playback
18416
+ * through the hub data-plane) instead of the live WebRTC stream. `clear()`
18417
+ * returns the hero to live.
18418
+ *
18419
+ * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
18420
+ * the Module-Federation boundary: an addon-shipped widget consuming
18421
+ * `useVodPlayback()` resolves the SAME provider instance the host mounted —
18422
+ * the same mechanism `player-overlays` / `zone-editing` use to let federated
18423
+ * widgets (zone-editor, privacy-mask) drive the live player. Consumers used
18424
+ * outside a provider get the inert default (no-op), so a StreamPanel mounted
18425
+ * on a page without the Recording widget stays live-only without crashing.
18426
+ */
18427
+ var VodPlaybackContext = createSharedContext("camstack:vod-playback", {
18428
+ request: null,
18429
+ play: () => {},
18430
+ clear: () => {}
18431
+ });
18432
+ function VodPlaybackProvider({ children }) {
18433
+ const [request, setRequest] = useState(null);
18434
+ const value = useMemo(() => ({
18435
+ request,
18436
+ play: (req) => setRequest(req),
18437
+ clear: () => setRequest(null)
18438
+ }), [request]);
18439
+ return /* @__PURE__ */ jsx(VodPlaybackContext.Provider, {
18440
+ value,
18441
+ children
18442
+ });
18443
+ }
18444
+ /** @returns the VOD playback controller (inert no-op outside a provider). */
18445
+ function useVodPlayback() {
18446
+ return useContext(VodPlaybackContext);
18447
+ }
18448
+ //#endregion
18449
+ //#region src/primitives/card.tsx
18450
+ var cardVariants = cva("rounded-lg p-3", {
18451
+ variants: { variant: {
18452
+ flat: "bg-surface",
18453
+ bordered: "bg-surface border border-border"
18454
+ } },
18455
+ defaultVariants: { variant: "bordered" }
18456
+ });
18457
+ var Card = forwardRef(({ className, variant, ...props }, ref) => {
18458
+ return /* @__PURE__ */ jsx("div", {
18459
+ ref,
18460
+ className: cn(cardVariants({ variant }), className),
18461
+ ...props
18462
+ });
18463
+ });
18464
+ Card.displayName = "Card";
18465
+ //#endregion
18466
+ //#region src/composites/clips/ClipPanel.tsx
18467
+ /**
18468
+ * The selected clip — what it is, how it plays, and how it is saved.
18469
+ *
18470
+ * **No second player.** `getClipPlayback` answers `{ playbackUrl, format,
18471
+ * served }` and the panel hands it to the SAME hero the recordings timeline
18472
+ * drives: `vod.play({ url, format })` → `StreamPanel playbackUrl/playbackFormat`
18473
+ * → `HlsVideo`'s `mp4` branch (`video.src`, no hls.js). Building a clip player
18474
+ * here is the thing D549 decision 22 exists to refuse.
18475
+ *
18476
+ * **`format` travels.** An MP4 handed to hls.js is parsed as a manifest and
18477
+ * fails, so the format the provider named is forwarded verbatim and never
18478
+ * defaulted here.
18479
+ *
18480
+ * **`served` is shown.** A clip that holds only one twin answers with the one
18481
+ * it has, and the surface SAYS so — a missing main twin must never be served
18482
+ * silently as if it were the asked-for quality (D549 decision 15). Both ids
18483
+ * are already on the row, so changing quality re-asks the hub and never
18484
+ * re-searches the camera.
18485
+ *
18486
+ * **Nothing is appended to the URL.** The route is `access:'authenticated'`
18487
+ * and the session cookie authenticates it (D549 decision 22); the same URL is
18488
+ * the download.
18489
+ */
18490
+ var PROFILES = [
18491
+ "low",
18492
+ "mid",
18493
+ "high"
18494
+ ];
18495
+ function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
18496
+ const vod = useVodPlayback();
18497
+ const playback = useVideoclipsGetClipPlayback({
18498
+ deviceId,
18499
+ clipId: clip.id,
18500
+ profile
18501
+ });
18502
+ const data = playback.data;
18503
+ const label = `${new Date(clip.timeRange.startMs).toLocaleString()} · ${clip.source}`;
18504
+ useEffect(() => {
18505
+ if (data === void 0) return;
18506
+ vod.play({
18507
+ url: hubPath(data.playbackUrl),
18508
+ ...data.format === void 0 ? {} : { format: data.format },
18509
+ label
18510
+ });
18511
+ }, [data, label]);
18512
+ return /* @__PURE__ */ jsxs(Card, {
18513
+ className: "space-y-2 text-sm",
18514
+ children: [
18515
+ /* @__PURE__ */ jsx("div", {
18516
+ className: "font-medium text-foreground",
18517
+ children: label
18518
+ }),
18519
+ /* @__PURE__ */ jsxs("div", {
18520
+ className: "flex items-center gap-1",
18521
+ children: [/* @__PURE__ */ jsx("span", {
18522
+ className: "mr-1 text-[10px] uppercase tracking-wide text-foreground-subtle",
18523
+ children: "Quality"
18524
+ }), PROFILES.map((option) => /* @__PURE__ */ jsx(Button, {
18525
+ variant: option === profile ? "primary" : "secondary",
18526
+ "aria-pressed": option === profile,
18527
+ onClick: () => onProfileChange(option),
18528
+ children: option
18529
+ }, option))]
18530
+ }),
18531
+ playback.status === "pending" ? /* @__PURE__ */ jsxs("div", {
18532
+ className: "space-y-1",
18533
+ children: [/* @__PURE__ */ jsx("div", {
18534
+ role: "status",
18535
+ className: "text-xs text-foreground-subtle",
18536
+ children: "Preparing the clip — the file is fetched and muxed once…"
18537
+ }), /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-48" })]
18538
+ }) : playback.status === "error" ? /* @__PURE__ */ jsx(ErrorBox, {
18539
+ title: "This clip could not be prepared",
18540
+ message: playback.error?.message ?? "no reason given"
18541
+ }) : data === void 0 ? null : /* @__PURE__ */ jsxs("div", {
18542
+ className: "space-y-2",
18543
+ children: [data.served === void 0 || data.served === profile ? null : /* @__PURE__ */ jsx(ErrorBox, {
18544
+ variant: "warning",
18545
+ message: /* @__PURE__ */ jsxs(Fragment$1, { children: [
18546
+ "Served the ",
18547
+ /* @__PURE__ */ jsx("strong", { children: data.served }),
18548
+ " stream — this clip has no ",
18549
+ profile,
18550
+ " ",
18551
+ "twin."
18552
+ ] })
18553
+ }), /* @__PURE__ */ jsxs("a", {
18554
+ href: hubPath(data.playbackUrl),
18555
+ download: true,
18556
+ className: "inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:underline",
18557
+ children: [/* @__PURE__ */ jsx(Download, {
18558
+ className: "h-3.5 w-3.5",
18559
+ "aria-hidden": "true"
18560
+ }), "Download this clip"]
18561
+ })]
18562
+ })
18563
+ ]
18564
+ });
18565
+ }
18566
+ //#endregion
18567
+ //#region src/composites/clips/clip-tile-media.ts
18568
+ /**
18569
+ * Why no still exists, in words. Keyed by the provider's own vocabulary so a
18570
+ * reason this table does not know still reaches the operator verbatim.
18571
+ */
18572
+ var REFUSAL_TEXT = {
18573
+ sleeping: "The camera is asleep — a still would cost a wake",
18574
+ "camera-refused": "The camera refused the still request",
18575
+ "no-keyframe": "No decodable frame inside this window",
18576
+ unsupported: "This source cannot produce stills",
18577
+ "queue-full": "The camera is busy — its still queue is full",
18578
+ "no-catalog-row": "This clip is no longer in the catalog"
18579
+ };
18580
+ function refusal(reason) {
18581
+ return {
18582
+ kind: "unavailable",
18583
+ reason,
18584
+ text: REFUSAL_TEXT[reason] ?? `Refused: ${reason}`
18585
+ };
18586
+ }
18587
+ /** What to draw for one clip's tile. */
18588
+ function resolveTileMedia(clip) {
18589
+ if (clip.thumbnail !== void 0) return {
18590
+ kind: "vouched",
18591
+ url: clip.thumbnail
18592
+ };
18593
+ if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason);
18594
+ if (clip.thumbnailMint !== void 0) return {
18595
+ kind: "mint",
18596
+ url: clip.thumbnailMint
18597
+ };
18598
+ return {
18599
+ kind: "none",
18600
+ text: "This clip carries no still and offered none"
18601
+ };
18602
+ }
18603
+ /**
18604
+ * A mint that answered 204 — latch its `x-camstack-reason` to this instant.
18605
+ *
18606
+ * The refusal is about NOW (a full queue, a sleeping camera), so it replaces
18607
+ * the offer for as long as the tile is mounted and is not retried behind the
18608
+ * operator's back. An unknown reason is shown raw rather than dropped: a
18609
+ * firmware inventing a refusal must not become a blank tile.
18610
+ */
18611
+ function latchMintRefusal(reason) {
18612
+ return refusal(reason);
18613
+ }
18614
+ //#endregion
18615
+ //#region src/composites/clips/clip-thumbnail-fetch.ts
18616
+ /**
18617
+ * Asking a `thumbnailMint` route for a still — and hearing its refusal.
18618
+ *
18619
+ * The route answers **200** with a JPEG or **204** with the reason in
18620
+ * `x-camstack-reason` (`queue-full`, `sleeping`, `camera-refused`,
18621
+ * `no-keyframe`, `no-catalog-row`). An `<img src>` cannot read that header:
18622
+ * a 204 fires `onerror` with nothing to say, turning a named refusal into an
18623
+ * anonymous broken tile. So a mint goes through `fetch`.
18624
+ *
18625
+ * **Nothing is appended to the URL.** Both data-plane routes are registered
18626
+ * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
18627
+ * the session cookie the admin UI sets at login, so the URL is never a
18628
+ * credential (D549 decision 22) — it may sit in history or a `Referer` and
18629
+ * open nothing.
18630
+ *
18631
+ * The refusal is about NOW and is LATCHED by the caller: a camera that just
18632
+ * said "busy" is not asked again behind the operator's back.
18633
+ */
18634
+ /**
18635
+ * Ask a mint route for one still.
18636
+ *
18637
+ * Never rejects: a thrown fetch is a refusal like any other, because a tile
18638
+ * that throws is a tile that renders nothing and says nothing — the exact
18639
+ * failure this module exists to prevent.
18640
+ */
18641
+ async function fetchMintedThumbnail(url, fetcher) {
18642
+ let response;
18643
+ try {
18644
+ response = await fetcher(url, { credentials: "same-origin" });
18645
+ } catch (err) {
18646
+ return latchMintRefusal(err instanceof Error ? err.message : String(err));
18647
+ }
18648
+ if (response.status === 204) return latchMintRefusal(response.headers.get("x-camstack-reason") ?? "no reason given");
18649
+ if (!response.ok) return latchMintRefusal(`HTTP ${String(response.status)}`);
18650
+ try {
18651
+ return {
18652
+ kind: "image",
18653
+ blob: await response.blob()
18654
+ };
18655
+ } catch (err) {
18656
+ return latchMintRefusal(err instanceof Error ? err.message : String(err));
18657
+ }
18658
+ }
18659
+ //#endregion
18660
+ //#region src/composites/clips/ClipTile.tsx
18661
+ /**
18662
+ * One clip, as a tile.
18663
+ *
18664
+ * The picture is resolved by `resolveTileMedia` — vouched JPEG, an offer, or
18665
+ * the reason there is none (D549 decision 5). An offer is fetched rather than
18666
+ * dropped into an `<img src>`, because the route's **204 + x-camstack-reason**
18667
+ * is invisible to an `<img>`: it fires `onerror` with nothing to say. The
18668
+ * refusal is LATCHED for the life of the tile and never retried behind the
18669
+ * operator's back.
18670
+ *
18671
+ * The row also carries what the camera itself said: its native types, the
18672
+ * behaviours beside the class, which storages hold it, and — for a row that
18673
+ * is an EVENT sitting inside no file — `playable: false` with its reason
18674
+ * (measured: 1 of 29 hub events on 3628). Such a row is SHOWN, never dropped
18675
+ * and never offered as playable-then-failing (D549 decision 19).
18676
+ *
18677
+ * The CHROME is the library's: a `Card` for the tile, `Badge` for every label
18678
+ * the camera gave it, `Skeleton` while a mint is in flight. Only the ladder
18679
+ * above is this file's own, because only it is a decision.
18680
+ */
18681
+ function clockOf(ms) {
18682
+ return new Date(ms).toLocaleTimeString(void 0, {
18683
+ hour: "2-digit",
18684
+ minute: "2-digit",
18685
+ second: "2-digit"
18686
+ });
18687
+ }
18688
+ /**
18689
+ * Ask a mint route once, and LATCH whatever it says.
18690
+ *
18691
+ * The offer is memoised on the three fields it is derived from, not rebuilt
18692
+ * per render: `resolveTileMedia` returns a fresh object every call, and an
18693
+ * effect keyed on that object re-runs on every commit — which, because the
18694
+ * effect sets state, is an infinite render loop. It was one here, and it hung
18695
+ * the suite rather than failing it.
18696
+ *
18697
+ * The refusal is latched for the life of the tile: a camera that just said
18698
+ * "busy" is not asked again behind the operator's back (D549 decision 5).
18699
+ */
18700
+ function useMintedStill(clip) {
18701
+ const offered = useMemo(() => resolveTileMedia(clip), [
18702
+ clip.thumbnail,
18703
+ clip.thumbnailMint,
18704
+ clip.thumbnailUnavailable?.reason
18705
+ ]);
18706
+ const [latched, setLatched] = useState(null);
18707
+ const mintUrl = offered.kind === "mint" ? offered.url : null;
18708
+ useEffect(() => {
18709
+ setLatched(null);
18710
+ if (mintUrl === null || typeof fetch !== "function") return void 0;
18711
+ let objectUrl = null;
18712
+ let cancelled = false;
18713
+ (async () => {
18714
+ const answer = await fetchMintedThumbnail(hubPath(mintUrl), fetch);
18715
+ if (cancelled) return;
17938
18716
  if (answer.kind === "image") {
17939
18717
  objectUrl = URL.createObjectURL(answer.blob);
17940
18718
  setLatched({
@@ -17956,47 +18734,52 @@ function ClipTile({ clip, selected, onSelect }) {
17956
18734
  const media = useMintedStill(clip);
17957
18735
  const playable = clip.playable !== false;
17958
18736
  const labels = [...clip.labels ?? [], ...clip.behaviours ?? []];
17959
- return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("button", {
18737
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("button", {
17960
18738
  type: "button",
17961
18739
  onClick: () => onSelect(clip),
17962
18740
  disabled: !playable,
17963
18741
  "aria-current": selected ? "true" : void 0,
17964
- className: `flex w-full gap-3 rounded-md border p-2 text-left ${selected ? "border-primary" : "border-border"} ${playable ? "" : "opacity-70"}`,
17965
- children: [/* @__PURE__ */ jsx("div", {
17966
- className: "flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded bg-muted text-[10px] text-muted-foreground",
17967
- children: media.kind === "vouched" ? /* @__PURE__ */ jsx("img", {
17968
- src: media.url,
17969
- alt: "",
17970
- className: "h-full w-full object-cover"
17971
- }) : media.kind === "mint" ? /* @__PURE__ */ jsx("span", { children: "minting…" }) : /* @__PURE__ */ jsx("span", {
17972
- className: "px-1 text-center",
17973
- children: media.text
17974
- })
17975
- }), /* @__PURE__ */ jsxs("div", {
17976
- className: "min-w-0 flex-1",
17977
- children: [
17978
- /* @__PURE__ */ jsx("div", {
17979
- className: "font-medium",
17980
- children: clockOf(clip.timeRange.startMs)
17981
- }),
17982
- /* @__PURE__ */ jsx("div", {
17983
- className: "text-xs text-muted-foreground",
17984
- children: clip.source
17985
- }),
17986
- labels.length === 0 ? null : /* @__PURE__ */ jsx("div", {
17987
- className: "text-xs",
17988
- children: labels.join(" · ")
17989
- }),
17990
- clip.nativeTypes === void 0 || clip.nativeTypes.length === 0 ? null : /* @__PURE__ */ jsx("div", {
17991
- className: "text-[10px] text-muted-foreground",
17992
- children: clip.nativeTypes.join(", ")
17993
- }),
17994
- playable ? null : /* @__PURE__ */ jsxs("div", {
17995
- className: "text-xs text-amber-600 dark:text-amber-400",
17996
- children: ["Not playable — ", clip.unplayableReason ?? "the source gave no reason"]
18742
+ className: "block w-full text-left disabled:cursor-not-allowed",
18743
+ children: /* @__PURE__ */ jsxs(Card, {
18744
+ className: `flex gap-3 transition-colors ${selected ? "border-primary" : ""} ${playable ? "hover:bg-surface-hover" : "opacity-70"}`,
18745
+ children: [/* @__PURE__ */ jsx("div", {
18746
+ className: "flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded bg-surface-hover text-[10px] text-foreground-subtle",
18747
+ children: media.kind === "vouched" ? /* @__PURE__ */ jsx("img", {
18748
+ src: media.url,
18749
+ alt: "",
18750
+ className: "h-full w-full object-cover"
18751
+ }) : media.kind === "mint" ? /* @__PURE__ */ jsx(Skeleton, { className: "h-full w-full rounded-none" }) : /* @__PURE__ */ jsx("span", {
18752
+ className: "px-1 text-center leading-tight",
18753
+ children: media.text
17997
18754
  })
17998
- ]
17999
- })]
18755
+ }), /* @__PURE__ */ jsxs("div", {
18756
+ className: "min-w-0 flex-1 space-y-1",
18757
+ children: [
18758
+ /* @__PURE__ */ jsx("div", {
18759
+ className: "text-sm font-medium text-foreground",
18760
+ children: clockOf(clip.timeRange.startMs)
18761
+ }),
18762
+ /* @__PURE__ */ jsx("div", {
18763
+ className: "text-[10px] text-foreground-subtle",
18764
+ children: clip.source
18765
+ }),
18766
+ labels.length === 0 && clip.nativeTypes === void 0 ? null : /* @__PURE__ */ jsxs("div", {
18767
+ className: "flex flex-wrap gap-1",
18768
+ children: [labels.map((label) => /* @__PURE__ */ jsx(Badge, {
18769
+ variant: "info",
18770
+ children: label
18771
+ }, `label:${label}`)), (clip.nativeTypes ?? []).map((native) => /* @__PURE__ */ jsx(Badge, {
18772
+ style: "outline",
18773
+ children: native
18774
+ }, `native:${native}`))]
18775
+ }),
18776
+ playable ? null : /* @__PURE__ */ jsxs("div", {
18777
+ className: "text-[11px] text-warning",
18778
+ children: ["Not playable — ", clip.unplayableReason ?? "the source gave no reason"]
18779
+ })
18780
+ ]
18781
+ })]
18782
+ })
18000
18783
  }) });
18001
18784
  }
18002
18785
  //#endregion
@@ -18105,12 +18888,6 @@ function formatCatalogAge(catalogAsOf, now) {
18105
18888
  }
18106
18889
  //#endregion
18107
18890
  //#region src/composites/clips/clip-view-state.ts
18108
- /** Keep only the rows of selected sources; an absent selection keeps them all. */
18109
- function applySelection(clips, selected) {
18110
- if (selected === void 0) return clips;
18111
- const allowed = new Set(selected);
18112
- return clips.filter((clip) => allowed.has(clip.source));
18113
- }
18114
18891
  function resolveClipsView(input) {
18115
18892
  if (input.clipsStatus === "error") return {
18116
18893
  kind: "failed",
@@ -18124,7 +18901,7 @@ function resolveClipsView(input) {
18124
18901
  const notices = input.sources.map(describeSource);
18125
18902
  const answered = notices.filter((notice) => notice.answered);
18126
18903
  const silent = notices.filter((notice) => !notice.answered);
18127
- const clips = applySelection(input.clips, input.selected);
18904
+ const clips = input.clips;
18128
18905
  if (clips.length === 0) return {
18129
18906
  kind: "empty",
18130
18907
  dayId: input.dayId,
@@ -18140,56 +18917,166 @@ function resolveClipsView(input) {
18140
18917
  };
18141
18918
  }
18142
18919
  //#endregion
18143
- //#region src/composites/clips/ClipsBrowser.tsx
18920
+ //#region src/composites/clips/ClipsSection.tsx
18144
18921
  /**
18145
- * `host/clips-browser` — a camera's clips, from EVERY source it has.
18922
+ * `host/clips-browser` — a camera's clips, from EVERY source it has, as the
18923
+ * LAST SECTION of the Recording tab.
18146
18924
  *
18147
18925
  * Mounted by the framework, not by hand: `videoclips.cap.ts` declares
18148
18926
  * `deviceConfig.ui = { kind:'widget', widgetId:'host/clips-browser',
18149
- * tab:'clips', topTab:true }` and `deriveDeviceConfigContribution` emits the
18150
- * section (D14). `DeviceDetail.tsx` is not edited — placement is data, and
18151
- * `device-config-widget-wrapped-binding.spec.ts` pins that the derive works
18152
- * for this cap's WRAPPED binding entry, which no shipped widget cap had
18153
- * exercised before.
18154
- *
18155
- * ## What it shows, and why every part of it exists
18156
- *
18157
- * A camera's clips live in several places at once — the analytics visit log,
18158
- * the camera's own SD card, a hub's storage — and the operator asked for all
18159
- * of them side by side, never one hidden behind another (D549 decision 2). So:
18927
+ * tab:'recording', topTab:true, order:100 }` and
18928
+ * `deriveDeviceConfigContribution` emits the section (D14). `DeviceDetail.tsx`
18929
+ * is not edited — placement is data. Because the admin Recordings page renders
18930
+ * every `location:'top-tab'` + `tab:'recording'` section behind its camera
18931
+ * picker (`CameraRecordingSettingsSection`), the same declaration puts this on
18932
+ * both surfaces with no second wiring.
18933
+ *
18934
+ * ## Built from the library, not from scratch
18935
+ *
18936
+ * It shipped as its own top tab made of five bespoke components, and the
18937
+ * operator rejected both: *"La UI è orribile, non segue la nostra UI affatto …
18938
+ * Io in realtà utilizzerei la stessa tab recordings, lì abbiamo già tutto il
18939
+ * necessario, una nuova sezione alla fine per le clips dove possiamo
18940
+ * selezionare le source con un multiselect common, non componenti nuovi
18941
+ * anonimi."* So every piece of chrome here is a component this repo already
18942
+ * has: `Select`, `DataTable`, `EmptyState`, `ErrorBox`, `Pagination`, `Card`,
18943
+ * `Badge`, `Button`, `Skeleton`. What the chrome CARRIES is unchanged, and it
18944
+ * is the feature:
18160
18945
  *
18161
18946
  * - **The source list is complete, always.** Every source the camera has,
18162
- * with its availability and the age of its catalog. Unticking one hides its
18163
- * ROWS; the source itself never leaves the list.
18947
+ * with its availability and the age of its catalog, whichever provider is
18948
+ * being asked. A source never leaves the table.
18164
18949
  * - **Nothing is deduped.** A clip held in two storages appears twice, once
18165
- * per source, and every row says which. That is stated as a consequence in
18166
- * D549 so it is not "fixed" later.
18950
+ * per source, and every tile says which (D549).
18167
18951
  * - **Four states are tellable apart**: loading, empty-with-a-cause,
18168
18952
  * degraded-per-source, and each silent source in its own words. Every one
18169
- * of them used to render as "no clips", which is the defect this whole
18170
- * line of work is about — a camera with no SD card (640) is not quiet, and
18171
- * a camera whose own index disagrees with its own calendar (618, 6–20
18953
+ * of them used to render as "no clips", which is the defect this whole line
18954
+ * of work is about — a camera with no SD card (640) is not quiet, and a
18955
+ * camera whose own index disagrees with its own calendar (618, 6–20
18172
18956
  * September) is not quiet either.
18173
18957
  *
18174
- * ## What the picker WRITES — nothing
18175
- *
18176
- * The tick boxes are the cap's own `sources` view filter, forwarded to
18177
- * `listClips`, and they persist nothing. The persistent authority for turning
18178
- * a source off for a camera is `deviceManager.setWrapperActive`, whose deny
18179
- * set is keyed by **addon id** (D556 decision 3) — and one addon serves TWO
18180
- * sources of a hub child (`native:reolink:onboard` and `native:reolink:hub`,
18181
- * both `provider-reolink`). Wiring a per-SOURCE tick box to a per-ADDON
18182
- * authority would make "hide the hub's event view" silently mean "turn off
18183
- * both Reolink sources": a switch that does something other than what it says,
18184
- * which is worse than no switch (D62). The gap and its fix are recorded in
18185
- * ADR 557; until a source row names its own provider, this surface offers the
18186
- * filter it can honour and no second store.
18958
+ * ## The picker WRITES nothing, and there is nothing for it to write
18959
+ *
18960
+ * A clip source is not enabled or disabled — it EXISTS, because of a
18961
+ * configuration that lives somewhere else. The operator put it exactly:
18962
+ * *"non è affatto necessario lo stato di abilitato o meno delle sources. Loro
18963
+ * vivono lì, ad esempio hksv c'è se la camera è esportata su homekit, reolink
18964
+ * la dà di suo così come il camstack."* HKSV is there if the camera is
18965
+ * exported to HomeKit; the Reolink source is there because Reolink is that
18966
+ * camera's provider; CamStack's is always there. None of that is a decision to
18967
+ * make in a clips list, so this surface offers no switch and writes no
18968
+ * authority (D557, amended). A directory-wide guard in
18969
+ * `clips-section-sources.test.tsx` keeps it that way.
18970
+ *
18971
+ * ## Why the picker is a SELECT and not a multiselect
18972
+ *
18973
+ * The operator asked for *"un multiselect common"* while `listClips` still
18974
+ * took `sources?: string[]`. It now takes ONE `provider` — *"il get videoclips
18975
+ * prendere un provider opzionale, di default va su camstack … l'utilizzatore è
18976
+ * uno solo"* — and a multi-pick control over a one-at-a-time argument offers a
18977
+ * sum the hub cannot answer. So the control is the shared single-choice one,
18978
+ * `Select`: the same component the Recordings page's own camera picker uses,
18979
+ * which is what *"segue la nostra UI"* means here. Absent means the camera's
18980
+ * BOUND provider, and the option that carries it is labelled with that
18981
+ * provider's own sources rather than the word "default".
18982
+ *
18983
+ * ## One provider is a fleet STATE
18984
+ *
18985
+ * `listSources` derives from `deviceManager.getBindings`, and today nothing
18986
+ * binds `provider-reolink` for `videoclips`, so a real camera answers with one
18987
+ * provider. The selector is then inert and SAYS why. An empty or silently
18988
+ * single-entry control would read as a defect; the binding store learning to
18989
+ * hold several providers for a collection cap is what changes this, and
18990
+ * nothing here needs to change with it.
18187
18991
  */
18188
18992
  /** Newest-first, and bounded: a busy day on 592 lists 546 clips. */
18189
18993
  var CLIP_PAGE_LIMIT = 500;
18190
- function ClipsBrowser({ deviceId }) {
18994
+ /** Tiles per page. The rest are a `Pagination` click away, never dropped. */
18995
+ var TILES_PER_PAGE = 60;
18996
+ var TONE_BADGE = {
18997
+ ok: "success",
18998
+ warn: "warning",
18999
+ error: "danger"
19000
+ };
19001
+ var SOURCE_COLUMNS = [
19002
+ {
19003
+ key: "source",
19004
+ header: "Source",
19005
+ render: (row) => /* @__PURE__ */ jsxs("div", {
19006
+ className: "min-w-0",
19007
+ children: [/* @__PURE__ */ jsx("div", {
19008
+ className: "font-medium text-foreground",
19009
+ children: row.notice.label
19010
+ }), /* @__PURE__ */ jsx("div", {
19011
+ className: "text-[10px] text-foreground-subtle",
19012
+ children: row.notice.source
19013
+ })]
19014
+ })
19015
+ },
19016
+ {
19017
+ key: "state",
19018
+ header: "State",
19019
+ nowrap: false,
19020
+ render: (row) => /* @__PURE__ */ jsx(Badge, {
19021
+ variant: TONE_BADGE[row.notice.tone],
19022
+ children: row.notice.headline
19023
+ })
19024
+ },
19025
+ {
19026
+ key: "reason",
19027
+ header: "Reason",
19028
+ nowrap: false,
19029
+ render: (row) => row.notice.detail === null ? /* @__PURE__ */ jsx("span", {
19030
+ className: "text-foreground-subtle",
19031
+ children: "—"
19032
+ }) : /* @__PURE__ */ jsx("span", {
19033
+ className: "break-words",
19034
+ children: row.notice.detail
19035
+ })
19036
+ },
19037
+ {
19038
+ key: "read",
19039
+ header: "Catalog read",
19040
+ render: (row) => /* @__PURE__ */ jsx("span", {
19041
+ className: "text-foreground-subtle",
19042
+ children: row.catalogAge ?? "live"
19043
+ })
19044
+ }
19045
+ ];
19046
+ /** The sources that contributed NOTHING to what is on screen, and why. */
19047
+ function SilentSources({ silent }) {
19048
+ if (silent.length === 0) return null;
19049
+ return /* @__PURE__ */ jsx(ErrorBox, {
19050
+ variant: "warning",
19051
+ title: silent.length === 1 ? "One source contributed nothing to this list" : `${String(silent.length)} sources contributed nothing to this list`,
19052
+ message: /* @__PURE__ */ jsx("div", {
19053
+ className: "space-y-1",
19054
+ children: silent.map((notice) => /* @__PURE__ */ jsxs("div", { children: [
19055
+ /* @__PURE__ */ jsx("span", {
19056
+ className: "font-medium",
19057
+ children: notice.label
19058
+ }),
19059
+ /* @__PURE__ */ jsxs("span", {
19060
+ className: "opacity-70",
19061
+ children: [
19062
+ " (",
19063
+ notice.source,
19064
+ ")"
19065
+ ]
19066
+ }),
19067
+ /* @__PURE__ */ jsx("div", { children: notice.headline }),
19068
+ notice.detail === null ? null : /* @__PURE__ */ jsx("div", {
19069
+ className: "opacity-80 break-words",
19070
+ children: notice.detail
19071
+ })
19072
+ ] }, notice.source))
19073
+ })
19074
+ });
19075
+ }
19076
+ function ClipsSection({ deviceId }) {
18191
19077
  const [dayId, setDayId] = useState(() => toDayId(/* @__PURE__ */ new Date()));
18192
- const [hidden, setHidden] = useState([]);
19078
+ const [provider, setProvider] = useState("");
19079
+ const [page, setPage] = useState(1);
18193
19080
  const [selectedClip, setSelectedClip] = useState(null);
18194
19081
  const [profile, setProfile] = useState("low");
18195
19082
  const presence = useCapabilityPresence("videoclips");
@@ -18199,115 +19086,186 @@ function ClipsBrowser({ deviceId }) {
18199
19086
  deviceId,
18200
19087
  since: window.since,
18201
19088
  until: window.until,
18202
- limit: CLIP_PAGE_LIMIT
19089
+ limit: CLIP_PAGE_LIMIT,
19090
+ ...provider === "" ? {} : { provider }
18203
19091
  });
18204
19092
  const sourceRows = sources.data ?? [];
18205
- const selected = sourceRows.map((row) => row.source).filter((source) => !hidden.includes(source));
18206
19093
  const view = resolveClipsView({
18207
19094
  dayId,
18208
19095
  clipsStatus: clips.status,
18209
19096
  clips: clips.data ?? [],
18210
19097
  sourcesStatus: sources.status,
18211
19098
  sources: sourceRows,
18212
- ...hidden.length === 0 ? {} : { selected },
18213
19099
  ...clips.error ? { clipsError: clips.error.message } : {},
18214
19100
  ...sources.error ? { sourcesError: sources.error.message } : {}
18215
19101
  });
18216
19102
  const now = Date.now();
18217
- const toggle = (source, next) => setHidden((current) => next ? current.filter((id) => id !== source) : current.includes(source) ? current : [...current, source]);
18218
- if (presence === "pending") return /* @__PURE__ */ jsx("div", {
18219
- role: "status",
18220
- children: "Asking the registry whether this cluster serves clips…"
19103
+ const options = useMemo(() => {
19104
+ const byAddon = /* @__PURE__ */ new Map();
19105
+ for (const row of sourceRows) {
19106
+ const addonId = row.addonId;
19107
+ if (addonId === void 0) continue;
19108
+ const labels = byAddon.get(addonId);
19109
+ if (labels === void 0) byAddon.set(addonId, [row.label]);
19110
+ else labels.push(row.label);
19111
+ }
19112
+ const rest = [...byAddon].map(([value, labels]) => ({
19113
+ value,
19114
+ label: labels.join(" · ")
19115
+ }));
19116
+ const first = rest[0];
19117
+ return [{
19118
+ value: "",
19119
+ label: first === void 0 ? "The camera’s bound provider" : first.label
19120
+ }, ...rest.slice(1)];
19121
+ }, [sourceRows]);
19122
+ const tableRows = useMemo(() => sourceRows.map((row) => ({
19123
+ notice: describeSource(row),
19124
+ catalogAge: formatCatalogAge(row.availability.catalogAsOf, now)
19125
+ })), [sourceRows]);
19126
+ const goToDay = (next) => {
19127
+ setDayId(next);
19128
+ setPage(1);
19129
+ };
19130
+ const pick = (next) => {
19131
+ setProvider(next);
19132
+ setPage(1);
19133
+ };
19134
+ if (presence === "pending") return /* @__PURE__ */ jsxs("div", {
19135
+ className: "space-y-2",
19136
+ children: [/* @__PURE__ */ jsx("div", {
19137
+ role: "status",
19138
+ className: "text-xs text-foreground-subtle",
19139
+ children: "Asking the registry whether this cluster serves clips…"
19140
+ }), /* @__PURE__ */ jsx(Skeleton, { className: "h-24 w-full" })]
18221
19141
  });
18222
- if (presence === "unknown") return /* @__PURE__ */ jsx("div", {
18223
- role: "alert",
18224
- children: "The capability registry could not be reached, so whether this camera has clip sources is unknown — not absent."
19142
+ if (presence === "unknown") return /* @__PURE__ */ jsx(ErrorBox, {
19143
+ variant: "warning",
19144
+ title: "Clip sources unknown",
19145
+ message: "The capability registry could not be reached, so whether this camera has clip sources is unknown — not absent."
18225
19146
  });
18226
- if (presence === "absent") return /* @__PURE__ */ jsxs("div", {
19147
+ if (presence === "absent") return /* @__PURE__ */ jsx("div", {
18227
19148
  role: "status",
18228
- children: [
18229
- "No provider of ",
18230
- /* @__PURE__ */ jsx("code", { children: "videoclips" }),
18231
- " is installed in this cluster."
18232
- ]
19149
+ children: /* @__PURE__ */ jsx(EmptyState, {
19150
+ icon: Clapperboard,
19151
+ title: "No provider of videoclips in this cluster",
19152
+ description: "Nothing is installed that could list a camera's stored clips."
19153
+ })
18233
19154
  });
19155
+ const visible = view.kind === "listed" ? view.clips : [];
19156
+ const pageClips = visible.slice((page - 1) * TILES_PER_PAGE, page * TILES_PER_PAGE);
18234
19157
  return /* @__PURE__ */ jsxs("div", {
18235
- className: "space-y-4",
19158
+ className: "space-y-3",
18236
19159
  children: [
18237
- /* @__PURE__ */ jsxs("section", {
18238
- "aria-label": "Clip sources",
18239
- children: [/* @__PURE__ */ jsx("h3", {
18240
- className: "text-sm font-semibold",
18241
- children: "Sources"
18242
- }), sources.status === "pending" ? /* @__PURE__ */ jsx("div", {
18243
- role: "status",
18244
- children: "Loading this camera's clip sources…"
18245
- }) : sourceRows.length === 0 ? /* @__PURE__ */ jsx("div", {
18246
- role: "status",
18247
- children: "No provider answered for this camera — nothing claims to hold clips for it."
18248
- }) : /* @__PURE__ */ jsx("ul", { children: sourceRows.map((row) => /* @__PURE__ */ jsx(ClipSourceRow, {
18249
- notice: describeSource(row),
18250
- catalogAge: formatCatalogAge(row.availability.catalogAsOf, now),
18251
- checked: !hidden.includes(row.source),
18252
- onToggle: toggle
18253
- }, row.source)) })]
18254
- }),
18255
- /* @__PURE__ */ jsxs("section", {
18256
- "aria-label": "Day",
18257
- className: "flex items-center gap-2",
19160
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h3", {
19161
+ className: "text-sm font-semibold text-foreground",
19162
+ children: "Clips"
19163
+ }), /* @__PURE__ */ jsx("p", {
19164
+ className: "mt-1 text-[11px] text-foreground-subtle",
19165
+ children: "Every source this camera has, side by side — CamStack's own recordings and whatever the camera or an export keeps of its own. A source is listed because it exists, not because it was switched on here; pick sources to narrow the view."
19166
+ })] }),
19167
+ /* @__PURE__ */ jsxs("div", {
19168
+ className: "flex flex-wrap items-center gap-2",
18258
19169
  children: [
18259
- /* @__PURE__ */ jsx("button", {
18260
- type: "button",
18261
- "aria-label": "Previous day",
18262
- onClick: () => setDayId(shiftDay(dayId, -1)),
18263
- children: "‹"
18264
- }),
18265
- /* @__PURE__ */ jsx("span", {
18266
- className: "font-medium",
18267
- children: formatDayLabel(dayId)
19170
+ /* @__PURE__ */ jsx("label", {
19171
+ htmlFor: "clips-provider",
19172
+ className: "text-[10px] uppercase tracking-wide text-foreground-subtle",
19173
+ children: "Source"
18268
19174
  }),
18269
- /* @__PURE__ */ jsx("button", {
18270
- type: "button",
18271
- "aria-label": "Next day",
18272
- onClick: () => setDayId(shiftDay(dayId, 1)),
18273
- children: "›"
19175
+ /* @__PURE__ */ jsx("div", {
19176
+ className: "w-60",
19177
+ children: /* @__PURE__ */ jsx(Select, {
19178
+ id: "clips-provider",
19179
+ options,
19180
+ value: provider,
19181
+ disabled: options.length < 2,
19182
+ onChange: (e) => pick(e.target.value)
19183
+ })
18274
19184
  }),
18275
- /* @__PURE__ */ jsx("button", {
18276
- type: "button",
18277
- onClick: () => setDayId(toDayId(/* @__PURE__ */ new Date())),
18278
- children: "Today"
19185
+ options.length < 2 ? /* @__PURE__ */ jsx("span", {
19186
+ className: "text-[11px] text-foreground-subtle",
19187
+ children: "This camera is bound to one clip provider today."
19188
+ }) : null,
19189
+ /* @__PURE__ */ jsxs("div", {
19190
+ className: "ml-auto flex items-center gap-1",
19191
+ children: [
19192
+ /* @__PURE__ */ jsx(Button, {
19193
+ variant: "ghost",
19194
+ "aria-label": "Previous day",
19195
+ onClick: () => goToDay(shiftDay(dayId, -1)),
19196
+ children: "‹"
19197
+ }),
19198
+ /* @__PURE__ */ jsx("span", {
19199
+ className: "px-1 text-xs font-medium text-foreground",
19200
+ children: formatDayLabel(dayId)
19201
+ }),
19202
+ /* @__PURE__ */ jsx(Button, {
19203
+ variant: "ghost",
19204
+ "aria-label": "Next day",
19205
+ onClick: () => goToDay(shiftDay(dayId, 1)),
19206
+ children: "›"
19207
+ }),
19208
+ /* @__PURE__ */ jsx(Button, {
19209
+ variant: "outline",
19210
+ onClick: () => goToDay(toDayId(/* @__PURE__ */ new Date())),
19211
+ children: "Today"
19212
+ })
19213
+ ]
18279
19214
  })
18280
19215
  ]
18281
19216
  }),
19217
+ /* @__PURE__ */ jsx("section", {
19218
+ "aria-label": "Clip sources",
19219
+ className: "space-y-1",
19220
+ children: sources.status === "pending" ? /* @__PURE__ */ jsx(Skeleton, { className: "h-16 w-full" }) : tableRows.length === 0 ? /* @__PURE__ */ jsx(EmptyState, {
19221
+ icon: Film,
19222
+ title: "No provider answered for this camera",
19223
+ description: "Nothing claims to hold clips for it."
19224
+ }) : /* @__PURE__ */ jsx(DataTable, {
19225
+ columns: SOURCE_COLUMNS,
19226
+ rows: [...tableRows],
19227
+ minWidthPx: 520
19228
+ })
19229
+ }),
18282
19230
  /* @__PURE__ */ jsx("section", {
18283
19231
  "aria-label": "Clips",
18284
- children: view.kind === "loading" ? /* @__PURE__ */ jsxs("div", {
19232
+ className: "space-y-2",
19233
+ children: view.kind === "loading" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
18285
19234
  role: "status",
19235
+ className: "text-xs text-foreground-subtle",
18286
19236
  children: [
18287
19237
  "Loading clips for ",
18288
19238
  formatDayLabel(dayId),
18289
19239
  "…"
18290
19240
  ]
18291
- }) : view.kind === "failed" ? /* @__PURE__ */ jsxs("div", {
18292
- role: "alert",
18293
- children: ["The clip list could not be read: ", view.message]
18294
- }) : view.kind === "empty" ? /* @__PURE__ */ jsxs("div", {
18295
- className: "space-y-2",
18296
- children: [/* @__PURE__ */ jsx("div", {
18297
- role: "status",
18298
- children: view.nobodyAnswered ? `No source could answer for ${formatDayLabel(dayId)}.` : `No clips on ${formatDayLabel(dayId)} from ${view.answered.map((notice) => notice.label).join(", ")}.`
18299
- }), /* @__PURE__ */ jsx(ClipSilentSources, { silent: view.silent })]
18300
- }) : /* @__PURE__ */ jsxs("div", {
18301
- className: "space-y-2",
18302
- children: [/* @__PURE__ */ jsx(ClipSilentSources, { silent: view.silent }), /* @__PURE__ */ jsx("ul", {
18303
- className: "space-y-2",
18304
- children: view.clips.map((clip) => /* @__PURE__ */ jsx(ClipTile, {
19241
+ }), /* @__PURE__ */ jsx(Skeleton, { className: "h-24 w-full" })] }) : view.kind === "failed" ? /* @__PURE__ */ jsx(ErrorBox, {
19242
+ title: "The clip list could not be read",
19243
+ message: view.message
19244
+ }) : view.kind === "empty" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
19245
+ role: "status",
19246
+ children: /* @__PURE__ */ jsx(EmptyState, {
19247
+ icon: Film,
19248
+ title: view.nobodyAnswered ? `No source could answer for ${formatDayLabel(dayId)}` : `No clips on ${formatDayLabel(dayId)}`,
19249
+ ...view.nobodyAnswered ? {} : { description: `Asked ${view.answered.map((notice) => notice.label).join(", ")}.` }
19250
+ })
19251
+ }), /* @__PURE__ */ jsx(SilentSources, { silent: view.silent })] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
19252
+ /* @__PURE__ */ jsx(SilentSources, { silent: view.silent }),
19253
+ /* @__PURE__ */ jsx("ul", {
19254
+ className: "grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3",
19255
+ children: pageClips.map((clip) => /* @__PURE__ */ jsx(ClipTile, {
18305
19256
  clip,
18306
19257
  selected: selectedClip?.id === clip.id,
18307
19258
  onSelect: setSelectedClip
18308
19259
  }, `${clip.source}:${clip.id}`))
18309
- })]
18310
- })
19260
+ }),
19261
+ /* @__PURE__ */ jsx(Pagination, {
19262
+ currentPage: page,
19263
+ totalItems: visible.length,
19264
+ pageSize: TILES_PER_PAGE,
19265
+ onPageChange: setPage,
19266
+ itemNoun: "clips"
19267
+ })
19268
+ ] })
18311
19269
  }),
18312
19270
  selectedClip === null ? null : /* @__PURE__ */ jsx(ClipPanel, {
18313
19271
  deviceId,
@@ -20438,82 +21396,6 @@ function useScrubController(sink, isPlayerLive = () => false) {
20438
21396
  };
20439
21397
  }
20440
21398
  //#endregion
20441
- //#region src/composites/empty-state.tsx
20442
- function EmptyState({ icon: Icon, title, description, action, className }) {
20443
- return /* @__PURE__ */ jsxs("div", {
20444
- className: cn("flex flex-col items-center justify-center gap-3 py-12", className),
20445
- children: [
20446
- Icon && /* @__PURE__ */ jsx(Icon, {
20447
- className: "h-12 w-12 text-foreground-subtle",
20448
- "aria-hidden": "true"
20449
- }),
20450
- /* @__PURE__ */ jsxs("div", {
20451
- className: "flex flex-col items-center gap-1 text-center",
20452
- children: [/* @__PURE__ */ jsx("p", {
20453
- className: "text-foreground-muted text-sm font-medium",
20454
- children: title
20455
- }), description && /* @__PURE__ */ jsx("p", {
20456
- className: "text-foreground-subtle text-xs max-w-xs",
20457
- children: description
20458
- })]
20459
- }),
20460
- action && /* @__PURE__ */ jsx("div", {
20461
- className: "mt-1",
20462
- children: action
20463
- })
20464
- ]
20465
- });
20466
- }
20467
- //#endregion
20468
- //#region src/composites/error-box.tsx
20469
- var VARIANT_STYLES$1 = {
20470
- danger: {
20471
- box: "border-danger/40 bg-danger/10",
20472
- icon: "text-danger",
20473
- title: "text-danger",
20474
- message: "text-danger"
20475
- },
20476
- warning: {
20477
- box: "border-amber-500/40 bg-amber-500/10",
20478
- icon: "text-amber-600 dark:text-amber-400",
20479
- title: "text-amber-700 dark:text-amber-300",
20480
- message: "text-amber-700 dark:text-amber-300"
20481
- }
20482
- };
20483
- function ErrorBox({ message, title, onRetry, action, variant = "danger", className }) {
20484
- const styles = VARIANT_STYLES$1[variant];
20485
- const Icon = variant === "warning" ? TriangleAlert : CircleAlert;
20486
- return /* @__PURE__ */ jsxs("div", {
20487
- role: "alert",
20488
- className: cn("flex items-start gap-2.5 rounded border px-3 py-2.5", styles.box, className),
20489
- children: [
20490
- /* @__PURE__ */ jsx(Icon, {
20491
- className: cn("h-4 w-4 mt-0.5 shrink-0", styles.icon),
20492
- "aria-hidden": "true"
20493
- }),
20494
- /* @__PURE__ */ jsxs("div", {
20495
- className: "flex-1 min-w-0",
20496
- children: [title && /* @__PURE__ */ jsx("p", {
20497
- className: cn("text-xs font-semibold", styles.title),
20498
- children: title
20499
- }), /* @__PURE__ */ jsx("div", {
20500
- className: cn("text-xs break-words", styles.message, title ? "mt-0.5" : ""),
20501
- children: message
20502
- })]
20503
- }),
20504
- action ?? (onRetry && /* @__PURE__ */ jsxs("button", {
20505
- type: "button",
20506
- onClick: onRetry,
20507
- className: cn("shrink-0 inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium", "border border-current/30 hover:bg-current/10 transition-colors", styles.message),
20508
- children: [/* @__PURE__ */ jsx(RefreshCw, {
20509
- className: "h-3 w-3",
20510
- "aria-hidden": "true"
20511
- }), "Retry"]
20512
- }))
20513
- ]
20514
- });
20515
- }
20516
- //#endregion
20517
21399
  //#region src/composites/cap-settings/recording-spans.ts
20518
21400
  /**
20519
21401
  * Merge availability ranges (across every profile) into the union of distinct
@@ -21697,7 +22579,7 @@ var HOST_WIDGETS = {
21697
22579
  "host/ptz-autotrack": AutotrackSection,
21698
22580
  "host/recording-panel": RecordingPanel,
21699
22581
  "host/camera-grid-layout": CameraGridLayoutEditor,
21700
- "host/clips-browser": ClipsBrowser
22582
+ "host/clips-browser": ClipsSection
21701
22583
  };
21702
22584
  //#endregion
21703
22585
  //#region src/composites/widget-slot.tsx
@@ -26004,92 +26886,6 @@ function AgentStepEditor(props) {
26004
26886
  });
26005
26887
  }
26006
26888
  //#endregion
26007
- //#region ../../node_modules/class-variance-authority/dist/index.mjs
26008
- /**
26009
- * Copyright 2022 Joe Bell. All rights reserved.
26010
- *
26011
- * This file is licensed to you under the Apache License, Version 2.0
26012
- * (the "License"); you may not use this file except in compliance with the
26013
- * License. You may obtain a copy of the License at
26014
- *
26015
- * http://www.apache.org/licenses/LICENSE-2.0
26016
- *
26017
- * Unless required by applicable law or agreed to in writing, software
26018
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
26019
- * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the
26020
- * License for the specific language governing permissions and limitations under
26021
- * the License.
26022
- */ var falsyToString = (value) => typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value;
26023
- var cx = clsx;
26024
- var cva = (base, config) => (props) => {
26025
- var _config_compoundVariants;
26026
- if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
26027
- const { variants, defaultVariants } = config;
26028
- const getVariantClassNames = Object.keys(variants).map((variant) => {
26029
- const variantProp = props === null || props === void 0 ? void 0 : props[variant];
26030
- const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];
26031
- if (variantProp === null) return null;
26032
- const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);
26033
- return variants[variant][variantKey];
26034
- });
26035
- const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param) => {
26036
- let [key, value] = param;
26037
- if (value === void 0) return acc;
26038
- acc[key] = value;
26039
- return acc;
26040
- }, {});
26041
- return cx(base, getVariantClassNames, config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param) => {
26042
- let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;
26043
- return Object.entries(compoundVariantOptions).every((param) => {
26044
- let [key, value] = param;
26045
- return Array.isArray(value) ? value.includes({
26046
- ...defaultVariants,
26047
- ...propsWithoutUndefined
26048
- }[key]) : {
26049
- ...defaultVariants,
26050
- ...propsWithoutUndefined
26051
- }[key] === value;
26052
- }) ? [
26053
- ...acc,
26054
- cvClass,
26055
- cvClassName
26056
- ] : acc;
26057
- }, []), props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
26058
- };
26059
- //#endregion
26060
- //#region src/primitives/button.tsx
26061
- var buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
26062
- variants: {
26063
- variant: {
26064
- primary: "bg-primary text-primary-foreground hover:bg-primary/90",
26065
- secondary: "bg-surface text-foreground hover:bg-surface-hover",
26066
- ghost: "hover:bg-surface-hover text-foreground-muted",
26067
- danger: "bg-danger text-white hover:bg-danger/90",
26068
- outline: "border border-border bg-transparent text-foreground hover:bg-surface-hover"
26069
- },
26070
- size: {
26071
- sm: "h-7 px-2.5 text-xs gap-1.5",
26072
- md: "h-8 px-3 text-sm gap-2",
26073
- lg: "h-9 px-4 text-sm gap-2"
26074
- }
26075
- },
26076
- defaultVariants: {
26077
- variant: "primary",
26078
- size: "sm"
26079
- }
26080
- });
26081
- var Button = forwardRef(({ className, variant, size, ...props }, ref) => {
26082
- return /* @__PURE__ */ jsx("button", {
26083
- ref,
26084
- className: cn(buttonVariants({
26085
- variant,
26086
- size
26087
- }), className),
26088
- ...props
26089
- });
26090
- });
26091
- Button.displayName = "Button";
26092
- //#endregion
26093
26889
  //#region src/primitives/icon-button.tsx
26094
26890
  var iconButtonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
26095
26891
  variants: {
@@ -26124,55 +26920,6 @@ var IconButton = forwardRef(({ className, variant, size, icon: Icon, ...props },
26124
26920
  });
26125
26921
  IconButton.displayName = "IconButton";
26126
26922
  //#endregion
26127
- //#region src/primitives/badge.tsx
26128
- var badgeVariants = cva("inline-flex items-center rounded-full text-xs font-medium px-2 py-0.5", {
26129
- variants: {
26130
- variant: {
26131
- default: "bg-surface-hover text-foreground",
26132
- success: "bg-success/15 text-success",
26133
- warning: "bg-warning/15 text-warning",
26134
- danger: "bg-danger/15 text-danger",
26135
- info: "bg-info/15 text-info"
26136
- },
26137
- styleVariant: {
26138
- solid: "",
26139
- outline: "border bg-transparent"
26140
- }
26141
- },
26142
- defaultVariants: {
26143
- variant: "default",
26144
- styleVariant: "solid"
26145
- }
26146
- });
26147
- var Badge = forwardRef(({ className, variant, style, ...props }, ref) => {
26148
- return /* @__PURE__ */ jsx("span", {
26149
- ref,
26150
- className: cn(badgeVariants({
26151
- variant,
26152
- styleVariant: style
26153
- }), className),
26154
- ...props
26155
- });
26156
- });
26157
- Badge.displayName = "Badge";
26158
- //#endregion
26159
- //#region src/primitives/card.tsx
26160
- var cardVariants = cva("rounded-lg p-3", {
26161
- variants: { variant: {
26162
- flat: "bg-surface",
26163
- bordered: "bg-surface border border-border"
26164
- } },
26165
- defaultVariants: { variant: "bordered" }
26166
- });
26167
- var Card = forwardRef(({ className, variant, ...props }, ref) => {
26168
- return /* @__PURE__ */ jsx("div", {
26169
- ref,
26170
- className: cn(cardVariants({ variant }), className),
26171
- ...props
26172
- });
26173
- });
26174
- Card.displayName = "Card";
26175
- //#endregion
26176
26923
  //#region src/primitives/label.tsx
26177
26924
  var Label = forwardRef(({ className, ...props }, ref) => {
26178
26925
  return /* @__PURE__ */ jsx("label", {
@@ -26201,16 +26948,6 @@ var Separator = forwardRef(({ className, orientation, ...props }, ref) => {
26201
26948
  });
26202
26949
  Separator.displayName = "Separator";
26203
26950
  //#endregion
26204
- //#region src/primitives/skeleton.tsx
26205
- var Skeleton = forwardRef(({ className, ...props }, ref) => {
26206
- return /* @__PURE__ */ jsx("div", {
26207
- ref,
26208
- className: cn("animate-pulse bg-surface-hover rounded-md h-4 w-full", className),
26209
- ...props
26210
- });
26211
- });
26212
- Skeleton.displayName = "Skeleton";
26213
- //#endregion
26214
26951
  //#region src/primitives/input.tsx
26215
26952
  /**
26216
26953
  * `disabled:opacity-50 disabled:cursor-not-allowed` for the same reason as
@@ -26252,43 +26989,6 @@ var Input = forwardRef(({ className, state, leftSlot, rightSlot, ...props }, ref
26252
26989
  });
26253
26990
  Input.displayName = "Input";
26254
26991
  //#endregion
26255
- //#region src/primitives/select.tsx
26256
- /**
26257
- * A native `<select>` styled to match the rest of the field set.
26258
- *
26259
- * `appearance-none` plus an explicit `bg-surface` / `text-foreground` is what
26260
- * makes it match — and it is also what USED to make a disabled one a lie. A
26261
- * browser greys a disabled control by rendering its own background and text
26262
- * colour; all three of those are overridden here, so a `disabled` dropdown
26263
- * looked exactly like a live one and simply ignored the click. That is the
26264
- * whole of the "the language selector does not open" report of 2026-08-15:
26265
- * the AI section's switch was off, every control under it was correctly inert,
26266
- * and nothing on screen said so.
26267
- *
26268
- * `disabled:` classes belong HERE and not on each call site: there are dozens
26269
- * of `<Select disabled>` in this app and the one that gets forgotten is the one
26270
- * that generates the ticket. Guarded by `disabled-affordance.test.tsx`.
26271
- */
26272
- var Select = forwardRef(({ className, options, disabled, ...props }, ref) => {
26273
- return /* @__PURE__ */ jsxs("div", {
26274
- className: "relative w-full",
26275
- children: [/* @__PURE__ */ jsx("select", {
26276
- ref,
26277
- disabled,
26278
- className: cn("h-7 w-full px-2.5 text-xs bg-surface border border-border rounded-md text-foreground appearance-none pr-7 focus:outline-none focus:ring-1 focus:ring-primary", "disabled:opacity-50 disabled:cursor-not-allowed", className),
26279
- ...props,
26280
- children: options.map((option) => /* @__PURE__ */ jsx("option", {
26281
- value: option.value,
26282
- children: option.label
26283
- }, option.value))
26284
- }), /* @__PURE__ */ jsx("div", {
26285
- className: cn("absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-foreground-muted", disabled === true ? "opacity-50" : ""),
26286
- children: /* @__PURE__ */ jsx(ChevronDown, { className: "h-3.5 w-3.5" })
26287
- })]
26288
- });
26289
- });
26290
- Select.displayName = "Select";
26291
- //#endregion
26292
26992
  //#region src/primitives/checkbox.tsx
26293
26993
  var Checkbox = forwardRef(({ className, ...props }, ref) => {
26294
26994
  return /* @__PURE__ */ jsx("input", {
@@ -26577,53 +27277,6 @@ function TooltipContent({ className, children, ...props }) {
26577
27277
  });
26578
27278
  }
26579
27279
  //#endregion
26580
- //#region src/hooks/use-is-mobile.ts
26581
- var MOBILE_QUERY = "(max-width: 767px)";
26582
- /**
26583
- * Mid breakpoint — below this width the sidebar collapses to an
26584
- * icons-only rail to reclaim canvas space. Above it (and outside
26585
- * mobile) the full sidebar renders. Threshold matches Tailwind `lg`
26586
- * so sidebar fans out at the same point grids switch to multi-column.
26587
- */
26588
- var MID_QUERY = "(min-width: 768px) and (max-width: 1023px)";
26589
- /**
26590
- * `matchMedia` is universal in browsers but absent in bare DOM environments.
26591
- * These hooks are called from shared primitives (`DataTable` picks its narrow
26592
- * layout with one), so throwing here takes down the whole page that merely
26593
- * rendered a table. Where the capability is missing, report the desktop
26594
- * layout — a widescreen rendering is wrong-looking; a crashed page is gone.
26595
- */
26596
- function matchQuery(query) {
26597
- if (typeof window.matchMedia !== "function") return null;
26598
- return window.matchMedia(query);
26599
- }
26600
- function subscribeQuery(query) {
26601
- return (callback) => {
26602
- const mql = matchQuery(query);
26603
- if (mql === null) return () => {};
26604
- mql.addEventListener("change", callback);
26605
- return () => mql.removeEventListener("change", callback);
26606
- };
26607
- }
26608
- function getSnapshot(query) {
26609
- return () => matchQuery(query)?.matches ?? false;
26610
- }
26611
- function getServerSnapshot() {
26612
- return false;
26613
- }
26614
- function useIsMobile() {
26615
- return useSyncExternalStore(subscribeQuery(MOBILE_QUERY), getSnapshot(MOBILE_QUERY), getServerSnapshot);
26616
- }
26617
- /**
26618
- * True when the viewport is in the medium (`md`) Tailwind range —
26619
- * 768px ≤ width < 1024px. Use to drive layouts that should pack denser
26620
- * than full-desktop but more legibly than mobile (e.g. icons-only
26621
- * sidebar rail, narrower stat tiles).
26622
- */
26623
- function useIsMidWidth() {
26624
- return useSyncExternalStore(subscribeQuery(MID_QUERY), getSnapshot(MID_QUERY), getServerSnapshot);
26625
- }
26626
- //#endregion
26627
27280
  //#region src/primitives/bottom-sheet.tsx
26628
27281
  function BottomSheet({ open, onClose, title, children, className }) {
26629
27282
  useEffect(() => {
@@ -29831,650 +30484,230 @@ function coverageLine(monitor) {
29831
30484
  const extra = [...covered].filter((c) => !SCENE_CAPTURE_VARIANTS.some((v) => v === c));
29832
30485
  for (const c of extra) parts.push(`${variantLabel(c)} ✓`);
29833
30486
  const missing = SCENE_CAPTURE_VARIANTS.filter((v) => !covered.has(v));
29834
- if (missing.length === 0) return parts.join(" · ");
29835
- if (covered.size === 0) return `${parts.join(" · ")} — nothing captured yet, so this scene is not watching.`;
29836
- const names = missing.map((v) => variantLabel(v).toLowerCase()).join(" and ");
29837
- return monitor.onUncoveredCondition === "judge-anyway" ? `${parts.join(" · ")} — ${names} judged against the references you do have.` : `${parts.join(" · ")} — optional; checks in ${names} are skipped.`;
29838
- }
29839
- /**
29840
- * The calm sentence for a scene that is sitting the current light out — `null`
29841
- * whenever it is checking normally.
29842
- *
29843
- * Separate from {@link statusLine} because it is not a verdict and must not
29844
- * read like one. A paused scene has not failed, has not lost its flag, and
29845
- * needs nothing from the operator; the only thing worth saying is which light
29846
- * it is waiting out and that it will pick itself back up.
29847
- */
29848
- function suspensionLine(monitor) {
29849
- if (monitor.suspendedCondition === null) return null;
29850
- const light = variantLabel(monitor.suspendedCondition).toLowerCase();
29851
- return `Paused — nothing was captured in ${light}, so checks are on hold until the light changes back. Capture ${light} if you want it watched around the clock.`;
29852
- }
29853
- var UNAVAILABLE_SENTENCE = {
29854
- "no-reference-for-condition": "can’t tell — nothing has been captured for this scene yet. Press “Capture daylight” to photograph what normal looks like.",
29855
- "view-shifted": "can’t tell — the camera view has moved since the reference was captured. Re-capture it.",
29856
- "no-vision-profile": "can’t tell — the vision model is unreachable, so the change is on hold.",
29857
- "encoder-model-changed": "can’t tell — the embedding model changed, so the stored references are unreadable. Re-capture them.",
29858
- "no-snapshot": "can’t tell — this camera did not return a frame."
29859
- };
29860
- /**
29861
- * The one line under a scene's name. It answers, in order: can it judge, what
29862
- * does it say, and how long has it said it.
29863
- */
29864
- function statusLine(monitor, now) {
29865
- if (monitor.states.length === 0) return "Not armed yet — capture the initial state to start watching.";
29866
- if (monitor.verdict === "unknown") return monitor.unavailable !== null ? UNAVAILABLE_SENTENCE[monitor.unavailable] : "can’t tell right now.";
29867
- const since = monitor.verdict === "diverged" ? monitor.divergedAt : monitor.armedAt;
29868
- const held = since !== null ? ` for ${humanDuration(Math.max(0, now - since))}` : "";
29869
- if (monitor.latched) return monitor.verdict === "diverged" ? `CHANGED${held} — press Reset when you have dealt with it.` : `Back to normal, but still flagged as changed${held}. Press Reset to clear it.`;
29870
- const paused = suspensionLine(monitor);
29871
- if (paused !== null) return paused;
29872
- return monitor.verdict === "diverged" ? `CHANGED${held}` : `Normal${held}`;
29873
- }
29874
- /** The badge word. `latched` wins over `verdict`, because the latch is the
29875
- * thing the operator asked to be told and it outlives the live state.
29876
- *
29877
- * A scene sitting out an uncovered light reads `Paused`, in the NEUTRAL tone —
29878
- * never `alarm`, and never the `danger` variant the page maps that to. A
29879
- * missing IR reference must not be able to paint a red badge on a scene that
29880
- * is doing exactly what it was configured to do. */
29881
- function badgeFor(monitor) {
29882
- if (monitor.states.length === 0) return {
29883
- text: "Not armed",
29884
- tone: "unknown"
29885
- };
29886
- if (monitor.latched) return {
29887
- text: "Changed",
29888
- tone: "alarm"
29889
- };
29890
- if (monitor.suspendedCondition !== null) return {
29891
- text: "Paused",
29892
- tone: "unknown"
29893
- };
29894
- if (monitor.verdict === "unknown") return {
29895
- text: "Can’t tell",
29896
- tone: "unknown"
29897
- };
29898
- return monitor.verdict === "diverged" ? {
29899
- text: "Changed",
29900
- tone: "alarm"
29901
- } : {
29902
- text: "Normal",
29903
- tone: "ok"
29904
- };
29905
- }
29906
- /** A rect covering most of the frame is dominated by weather, sky and parked
29907
- * cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
29908
- * failure is silent INSENSITIVITY, which is the worst kind — hence a warning
29909
- * rather than a refusal. */
29910
- var ROI_AREA_WARN_FRACTION = .4;
29911
- function roiTooLarge(roi) {
29912
- return roi.width * roi.height > ROI_AREA_WARN_FRACTION;
29913
- }
29914
- function humanDuration(ms) {
29915
- const s = Math.floor(ms / 1e3);
29916
- if (s < 60) return `${s}s`;
29917
- const m = Math.floor(s / 60);
29918
- if (m < 60) return `${m}m`;
29919
- const h = Math.floor(m / 60);
29920
- if (h < 48) return `${h}h`;
29921
- return `${Math.floor(h / 24)}d`;
29922
- }
29923
- //#endregion
29924
- //#region src/composites/code-block.tsx
29925
- function CodeBlock({ children, maxHeight = 300, className }) {
29926
- const [copied, setCopied] = useState(false);
29927
- const handleCopy = useCallback(() => {
29928
- navigator.clipboard.writeText(children).then(() => {
29929
- setCopied(true);
29930
- setTimeout(() => setCopied(false), 2e3);
29931
- });
29932
- }, [children]);
29933
- return /* @__PURE__ */ jsxs("div", {
29934
- className: cn("relative group", className),
29935
- children: [/* @__PURE__ */ jsx(ScrollArea, {
29936
- style: { maxHeight },
29937
- children: /* @__PURE__ */ jsx("pre", {
29938
- className: "font-mono text-xs bg-surface p-3 rounded-md border border-border-subtle",
29939
- children: /* @__PURE__ */ jsx("code", { children })
29940
- })
29941
- }), /* @__PURE__ */ jsx("div", {
29942
- className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",
29943
- children: /* @__PURE__ */ jsx(IconButton, {
29944
- icon: copied ? Check : Copy,
29945
- "aria-label": "Copy code",
29946
- variant: "ghost",
29947
- size: "sm",
29948
- onClick: handleCopy
29949
- })
29950
- })]
29951
- });
29952
- }
29953
- //#endregion
29954
- //#region src/composites/confirm-action-button.tsx
29955
- /**
29956
- * ConfirmActionButton — destructive-action button with a modal confirm.
29957
- *
29958
- * Two-step UX for any operation operators shouldn't trigger by accident:
29959
- * reboot device, restart addon, regenerate token, etc. The trigger is a
29960
- * normal Button that opens a Dialog; confirming runs the async action,
29961
- * the trigger spinner-disables itself for the duration, and the dialog
29962
- * closes on success. Errors surface inline at the dialog's bottom.
29963
- *
29964
- * Generic on `TResult` so callers don't have to discard the return
29965
- * value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
29966
- * for reboot, RefreshCw for restart).
29967
- */
29968
- function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
29969
- const [open, setOpen] = useState(false);
29970
- const [running, setRunning] = useState(false);
29971
- const [error, setError] = useState(null);
29972
- const handleConfirm = async () => {
29973
- setError(null);
29974
- setRunning(true);
29975
- try {
29976
- const result = await action();
29977
- onSuccess?.(result);
29978
- setOpen(false);
29979
- } catch (err) {
29980
- setError(err instanceof Error ? err.message : String(err));
29981
- } finally {
29982
- setRunning(false);
29983
- }
29984
- };
29985
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
29986
- type: "button",
29987
- variant: triggerVariant,
29988
- size,
29989
- disabled,
29990
- onClick: () => {
29991
- setError(null);
29992
- setOpen(true);
29993
- },
29994
- className,
29995
- children: [Icon && /* @__PURE__ */ jsx(Icon, { className: "h-3.5 w-3.5" }), label]
29996
- }), /* @__PURE__ */ jsx(Dialog, {
29997
- open,
29998
- onOpenChange: (next) => !running && setOpen(next),
29999
- children: /* @__PURE__ */ jsxs(DialogContent, { children: [
30000
- /* @__PURE__ */ jsxs(DialogHeader, { children: [/* @__PURE__ */ jsxs(DialogTitle, {
30001
- className: "flex items-center gap-2",
30002
- children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
30003
- }), typeof description === "string" ? /* @__PURE__ */ jsx(DialogDescription, { children: description }) : description] }),
30004
- error && /* @__PURE__ */ jsx("div", {
30005
- className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
30006
- children: error
30007
- }),
30008
- /* @__PURE__ */ jsxs(DialogFooter, { children: [/* @__PURE__ */ jsx(Button, {
30009
- type: "button",
30010
- variant: "ghost",
30011
- size,
30012
- disabled: running,
30013
- onClick: () => setOpen(false),
30014
- children: "Cancel"
30015
- }), /* @__PURE__ */ jsxs(Button, {
30016
- type: "button",
30017
- variant: confirmVariant,
30018
- size,
30019
- disabled: running,
30020
- onClick: () => {
30021
- handleConfirm();
30022
- },
30023
- children: [running && /* @__PURE__ */ jsx(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
30024
- })] })
30025
- ] })
30026
- })] });
30027
- }
30028
- //#endregion
30029
- //#region src/composites/copy-button.tsx
30030
- /**
30031
- * `CopyButton` — a compact button that copies a string `value` to the
30032
- * clipboard via `navigator.clipboard.writeText`, showing a brief
30033
- * "copied" check state. Addon-agnostic; used wherever a secret / URL
30034
- * needs a one-click copy affordance (export setup panels, etc.).
30035
- */
30036
- var COPIED_RESET_MS = 2e3;
30037
- function CopyButton({ value, label, srLabel, className, disabled }) {
30038
- const [copied, setCopied] = useState(false);
30039
- const handleCopy = useCallback(() => {
30040
- if (!value) return;
30041
- navigator.clipboard.writeText(value).then(() => {
30042
- setCopied(true);
30043
- setTimeout(() => setCopied(false), COPIED_RESET_MS);
30044
- });
30045
- }, [value]);
30046
- return /* @__PURE__ */ jsxs(Button, {
30047
- size: "sm",
30048
- variant: "ghost",
30049
- type: "button",
30050
- disabled: disabled || value.length === 0,
30051
- onClick: handleCopy,
30052
- className: cn(className),
30053
- "aria-label": copied ? "Copied" : `Copy ${srLabel ?? label ?? "value"}`,
30054
- children: [copied ? /* @__PURE__ */ jsx(Check, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsx(Copy, { className: "h-3.5 w-3.5" }), label ? /* @__PURE__ */ jsx("span", {
30055
- className: "ml-1",
30056
- children: copied ? "Copied" : label
30057
- }) : null]
30058
- });
30059
- }
30060
- //#endregion
30061
- //#region src/composites/data-table-layout.ts
30062
- /**
30063
- * At this many columns and above, `auto` switches a narrow viewport to cards.
30064
- * Three columns still fit a phone at a readable size; four do not.
30065
- */
30066
- var CARD_MODE_MIN_COLUMNS = 4;
30067
- function resolveTableLayout({ mode, columnCount, isNarrow }) {
30068
- if (!isNarrow) return "table";
30069
- if (mode === "scroll") return "table";
30070
- if (mode === "cards") return "cards";
30071
- if (columnCount === 0) return "table";
30072
- return columnCount >= 4 ? "cards" : "table";
30073
- }
30074
- //#endregion
30075
- //#region src/lib/responsive.ts
30076
- /**
30077
- * Responsive design tokens — three-tier system.
30078
- *
30079
- * The admin-ui targets three width tiers; every component should pick
30080
- * the right token from this file instead of hand-coding breakpoints.
30081
- * Adopting the tokens uniformly is what makes the app feel consistent
30082
- * across operator devices (mobile / tablet / desktop) and what unlocks
30083
- * targeted tuning (e.g. shrink every label by 1px in one place).
30084
- *
30085
- * Tier definitions (Tailwind breakpoints):
30086
- *
30087
- * • **L1 — Compact** (`< 640px`, default = mobile portrait)
30088
- * Single column, tight padding, smallest text, icon-only buttons
30089
- * where labels can be inferred. The operator can still see every
30090
- * stat / action — no horizontal scroll, no clipped text. The user
30091
- * directive: "molta informazione in poco spazio quando la width è
30092
- * bassa."
30093
- *
30094
- * • **L2 — Standard** (`sm` — `640–1023px`, tablet + small laptop)
30095
- * 2-column grids, side panels become visible, button labels return.
30096
- * Targets ~768px–1024px viewports — the most common operator sizes.
30097
- *
30098
- * • **L3 — Spacious** (`lg+` — `≥ 1024px`, desktop)
30099
- * 4-column grids, generous padding, full-width header text,
30100
- * side-by-side composite layouts. The operator has elbow room.
30101
- *
30102
- * Conventions:
30103
- * - **Always start from L1** — the bare class is always the compact
30104
- * variant, then `sm:` adds room at 640px, `lg:` at 1024px. No
30105
- * desktop-first breakpoints; no max-width queries.
30106
- * - **Token strings are static** — kept as `as const` so callers can
30107
- * compose them via `cn()` / template literals without running a
30108
- * class generator at runtime.
30109
- * - **Pair the token with a comment** — the comment in the file
30110
- * names every breakpoint it crosses, so a reader can predict the
30111
- * layout without going to Tailwind docs.
30112
- */
30113
- /** Container padding for a card-style section (header + body). */
30114
- var SECTION_CARD = "rounded-lg border border-border bg-surface overflow-hidden";
30115
- /** Header strip inside a card (between border-b lines). */
30116
- var SECTION_HEADER = "border-b border-border px-3 sm:px-4 py-1.5 sm:py-2";
30117
- /** Body padding for a card-style section. */
30118
- var SECTION_BODY = "p-2 sm:p-3 lg:p-4";
30119
- /** Gap between cards in a vertical stack. */
30120
- var STACK_GAP = "space-y-3 sm:space-y-4";
30121
- /** Gap between cards in a grid (4 cells / row at lg). */
30122
- var GRID_GAP = "gap-2 sm:gap-3";
30123
- /** Row padding inside a divided list (e.g. InfoRow). */
30124
- var LIST_ROW = "px-3 sm:px-4 py-1.5 sm:py-2";
30125
- /** 1-up at L1, 2-up at L2, 4-up at L3 — quick-stat row. */
30126
- var GRID_QUICK_STATS = "grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4";
30127
- /** 1-up at L1, 2-up at L2+ — paired panels (motion + detection rules). */
30128
- var GRID_PAIRED = "grid grid-cols-1 lg:grid-cols-2";
30129
- /** Side-by-side layout that stacks below md (canvas + side panel). */
30130
- var SPLIT_PANEL_OUTER = "flex flex-col-reverse md:flex-row";
30131
- /** Sidebar narrow lane in a SPLIT_PANEL_OUTER layout. */
30132
- var SPLIT_PANEL_SIDE = "w-full md:w-44 lg:w-52 md:flex-shrink-0 border-b md:border-b-0 md:border-l border-border";
30133
- /**
30134
- * The tier boundary at which a settings label/value row stops stacking.
30135
- *
30136
- * Below it (L1 — compact) the row is ONE column: label on its own line, value
30137
- * on the next at full width. Above it the row is label-left / value-right as
30138
- * before. The two-column form needs `SETTING_ROW_LABEL`'s lane plus a readable
30139
- * value beside it; a phone has neither, and the failure mode is not "cramped"
30140
- * but "the value is the part that gets cut" — the one thing the operator
30141
- * opened the page to read.
30142
- */
30143
- var SETTING_ROW_STACK_BREAKPOINT = "sm";
30144
- /** Outer row: single column at L1, label/value columns from `sm` up. */
30145
- var SETTING_ROW = "flex min-w-0 flex-col gap-0.5 py-1.5 sm:flex-row sm:items-center";
30146
- /** Label lane. Full width (and free to wrap) at L1; a fixed lane from `sm` up. */
30147
- var SETTING_ROW_LABEL = "min-w-0 text-[11px] leading-tight text-foreground-subtle sm:w-32 sm:shrink-0 sm:pr-2";
30148
- /**
30149
- * Value lane — the value text plus its trailing affordances (copy / reveal).
30150
- * `w-full` at L1 so the value owns the whole row once the label is above it;
30151
- * `sm:flex-1` makes it share the row again from `sm` up.
30152
- */
30153
- var SETTING_ROW_VALUE = "flex w-full min-w-0 items-center gap-1 sm:flex-1";
30154
- /**
30155
- * The value text itself. `break-all` lets an opaque token (a key, a URL) wrap
30156
- * at L1 instead of being cut mid-word; `sm:truncate` restores the single-line
30157
- * desktop form, where the row is wide enough for truncation to be a choice
30158
- * rather than data loss.
30159
- */
30160
- var SETTING_ROW_VALUE_TEXT = "min-w-0 break-all text-xs text-foreground sm:truncate";
30161
- /** Section header label (uppercase tracking-wider). */
30162
- var TEXT_SECTION_LABEL = "text-[10px] sm:text-[11px] font-semibold text-foreground uppercase tracking-wider";
30163
- /** Field label inside a row. */
30164
- var TEXT_FIELD_LABEL = "text-[11px] sm:text-xs text-foreground";
30165
- /** Sub-label / hint text. */
30166
- var TEXT_HINT = "text-[10px] text-foreground-subtle";
30167
- /** Primary value (a metric, badge, …). */
30168
- var TEXT_VALUE = "text-[11px] sm:text-xs font-medium text-foreground";
30169
- /** Big metric value (StatCard). */
30170
- var TEXT_METRIC = "text-base sm:text-lg font-semibold leading-tight text-foreground";
30171
- /** Default compact button. */
30172
- var BTN_COMPACT = "inline-flex items-center justify-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-foreground hover:bg-surface-hover transition-colors disabled:opacity-40";
30173
- /** Primary compact button. */
30174
- var BTN_COMPACT_PRIMARY = "inline-flex items-center justify-center gap-1 rounded border border-primary/40 bg-primary/15 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-primary hover:bg-primary/25 transition-colors disabled:opacity-40";
30175
- /** Danger compact button. */
30176
- var BTN_COMPACT_DANGER = "inline-flex items-center justify-center gap-1 rounded border border-danger/40 bg-danger/10 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-danger hover:bg-danger/20 transition-colors disabled:opacity-40";
30177
- /** Warning compact button (e.g. cancel-drawing). */
30178
- var BTN_COMPACT_WARNING = "inline-flex items-center justify-center gap-1 rounded border border-warning/40 bg-warning/10 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-warning hover:bg-warning/20 transition-colors disabled:opacity-40";
30179
- /** Compact text/select/number input. */
30180
- var INPUT_COMPACT = "w-full rounded border border-border bg-background px-1.5 py-0.5 sm:px-2 sm:py-1 text-[11px] sm:text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none";
30181
- /** Chip-style toggle (zone / class selectors in ZoneRulesEditor). */
30182
- var CHIP_BASE = "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors";
30183
- var CHIP_ACTIVE = "border-primary/50 bg-primary/15 text-primary";
30184
- var CHIP_INACTIVE = "border-border bg-surface text-foreground-subtle hover:bg-surface-hover";
30185
- //#endregion
30186
- //#region src/composites/setting-row.tsx
30187
- function SettingRow({ label, children, actions, elements = "plain", className, valueClassName }) {
30188
- const LabelTag = elements === "description" ? "dt" : "span";
30189
- const ValueTag = elements === "description" ? "dd" : "div";
30190
- return /* @__PURE__ */ jsxs("div", {
30191
- "data-setting-row": "",
30192
- className: cn(SETTING_ROW, className),
30193
- children: [/* @__PURE__ */ jsx(LabelTag, {
30194
- "data-setting-row-label": "",
30195
- className: SETTING_ROW_LABEL,
30196
- children: label
30197
- }), /* @__PURE__ */ jsxs(ValueTag, {
30198
- "data-setting-row-value": "",
30199
- className: SETTING_ROW_VALUE,
30200
- children: [/* @__PURE__ */ jsx("span", {
30201
- "data-setting-row-value-text": "",
30202
- className: cn(SETTING_ROW_VALUE_TEXT, valueClassName),
30203
- children
30204
- }), actions !== void 0 && /* @__PURE__ */ jsx("span", {
30205
- className: "flex shrink-0 items-center gap-0.5",
30206
- children: actions
30207
- })]
30208
- })]
30209
- });
30210
- }
30211
- //#endregion
30212
- //#region src/composites/sort-header-button.tsx
30213
- function SortHeaderButton({ label, active, dir, onSort }) {
30214
- return /* @__PURE__ */ jsxs("button", {
30215
- type: "button",
30216
- onClick: onSort,
30217
- className: "group/sort inline-flex items-center gap-0.5 hover:text-foreground transition-colors",
30218
- children: [/* @__PURE__ */ jsx("span", { children: label }), active ? dir === "asc" ? /* @__PURE__ */ jsx(ChevronUp, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ jsx(ChevronDown, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ jsx(ChevronUp, { className: cn("h-3 w-3 flex-shrink-0 opacity-0 transition-opacity", "group-hover/sort:opacity-30") })]
30219
- });
30220
- }
30221
- //#endregion
30222
- //#region src/composites/table-sort.ts
30223
- /**
30224
- * The toggle rule, and the only copy of it.
30225
- *
30226
- * none → asc → desc → none, and a click on a DIFFERENT column starts that
30227
- * column fresh at `asc`. `sameColumn` says whether the clicked column is the
30228
- * one currently sorted; `currentDir` is its direction (`null` when nothing is
30229
- * sorted). Returns the next direction, or `null` for "unsorted".
30230
- *
30231
- * Split out from {@link nextTableSort} so the device list — whose sort state
30232
- * keys columns by `columnId`, not `columnKey` — can reuse the rule without a
30233
- * cast and without a second copy of it.
30234
- */
30235
- function nextSortDirection(sameColumn, currentDir) {
30236
- if (!sameColumn || currentDir === null) return "asc";
30237
- return currentDir === "asc" ? "desc" : null;
30238
- }
30239
- /**
30240
- * Toggle a column's sort on header click: none → asc → desc → none.
30241
- * Clicking a DIFFERENT column starts it fresh at `asc`.
30242
- */
30243
- function nextTableSort(current, columnKey) {
30244
- const dir = nextSortDirection(current !== null && current.columnKey === columnKey, current === null ? null : current.dir);
30245
- return dir === null ? null : {
30246
- columnKey,
30247
- dir
30248
- };
30487
+ if (missing.length === 0) return parts.join(" · ");
30488
+ if (covered.size === 0) return `${parts.join(" · ")} — nothing captured yet, so this scene is not watching.`;
30489
+ const names = missing.map((v) => variantLabel(v).toLowerCase()).join(" and ");
30490
+ return monitor.onUncoveredCondition === "judge-anyway" ? `${parts.join(" · ")} — ${names} judged against the references you do have.` : `${parts.join(" · ")} — optional; checks in ${names} are skipped.`;
30249
30491
  }
30250
30492
  /**
30251
- * Ascending comparison of two cell values.
30252
- *
30253
- * Numbers compare numerically and strings compare with `localeCompare` — a
30254
- * numeric column must never be ordered as text ("100" before "9"), and a text
30255
- * column must respect the locale's collation. A mixed column (one row a
30256
- * number, another a string) falls back to string comparison of both, which is
30257
- * the only total order available; it is not a shape any caller should build on
30258
- * purpose.
30493
+ * The calm sentence for a scene that is sitting the current light out — `null`
30494
+ * whenever it is checking normally.
30259
30495
  *
30260
- * `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
30261
- * because its placement is direction-independent and a comparator that is
30262
- * multiplied by the direction factor cannot express that.
30496
+ * Separate from {@link statusLine} because it is not a verdict and must not
30497
+ * read like one. A paused scene has not failed, has not lost its flag, and
30498
+ * needs nothing from the operator; the only thing worth saying is which light
30499
+ * it is waiting out and that it will pick itself back up.
30263
30500
  */
30264
- function compareValues(a, b) {
30265
- if (typeof a === "number" && typeof b === "number") return a - b;
30266
- return String(a).localeCompare(String(b));
30501
+ function suspensionLine(monitor) {
30502
+ if (monitor.suspendedCondition === null) return null;
30503
+ const light = variantLabel(monitor.suspendedCondition).toLowerCase();
30504
+ return `Paused — nothing was captured in ${light}, so checks are on hold until the light changes back. Capture ${light} if you want it watched around the clock.`;
30267
30505
  }
30506
+ var UNAVAILABLE_SENTENCE = {
30507
+ "no-reference-for-condition": "can’t tell — nothing has been captured for this scene yet. Press “Capture daylight” to photograph what normal looks like.",
30508
+ "view-shifted": "can’t tell — the camera view has moved since the reference was captured. Re-capture it.",
30509
+ "no-vision-profile": "can’t tell — the vision model is unreachable, so the change is on hold.",
30510
+ "encoder-model-changed": "can’t tell — the embedding model changed, so the stored references are unreadable. Re-capture them.",
30511
+ "no-snapshot": "can’t tell — this camera did not return a frame."
30512
+ };
30268
30513
  /**
30269
- * Return a NEW array of `rows` ordered by `sort`.
30270
- *
30271
- * `valueOf` is the column's `sortValue` — it is called once per row per
30272
- * comparison, so it must be cheap (read a field, do not format a string).
30273
- * `sort === null` ⇒ a fresh shallow copy in the incoming order.
30514
+ * The one line under a scene's name. It answers, in order: can it judge, what
30515
+ * does it say, and how long has it said it.
30274
30516
  */
30275
- function sortRowsByColumn(rows, sort, valueOf) {
30276
- if (sort === null) return [...rows];
30277
- const factor = sort.dir === "asc" ? 1 : -1;
30278
- return rows.toSorted((a, b) => {
30279
- const av = valueOf(a);
30280
- const bv = valueOf(b);
30281
- if (av === null || bv === null) {
30282
- if (av === bv) return 0;
30283
- return av === null ? 1 : -1;
30284
- }
30285
- return factor * compareValues(av, bv);
30286
- });
30517
+ function statusLine(monitor, now) {
30518
+ if (monitor.states.length === 0) return "Not armed yet — capture the initial state to start watching.";
30519
+ if (monitor.verdict === "unknown") return monitor.unavailable !== null ? UNAVAILABLE_SENTENCE[monitor.unavailable] : "can’t tell right now.";
30520
+ const since = monitor.verdict === "diverged" ? monitor.divergedAt : monitor.armedAt;
30521
+ const held = since !== null ? ` for ${humanDuration(Math.max(0, now - since))}` : "";
30522
+ if (monitor.latched) return monitor.verdict === "diverged" ? `CHANGED${held} — press Reset when you have dealt with it.` : `Back to normal, but still flagged as changed${held}. Press Reset to clear it.`;
30523
+ const paused = suspensionLine(monitor);
30524
+ if (paused !== null) return paused;
30525
+ return monitor.verdict === "diverged" ? `CHANGED${held}` : `Normal${held}`;
30287
30526
  }
30288
- /** Derive the `<th>` `aria-sort` for `columnKey` from the table's sort state. */
30289
- function ariaSortForColumn(sort, columnKey) {
30290
- if (sort === null || sort.columnKey !== columnKey) return "none";
30291
- return sort.dir === "asc" ? "ascending" : "descending";
30527
+ /** The badge word. `latched` wins over `verdict`, because the latch is the
30528
+ * thing the operator asked to be told and it outlives the live state.
30529
+ *
30530
+ * A scene sitting out an uncovered light reads `Paused`, in the NEUTRAL tone —
30531
+ * never `alarm`, and never the `danger` variant the page maps that to. A
30532
+ * missing IR reference must not be able to paint a red badge on a scene that
30533
+ * is doing exactly what it was configured to do. */
30534
+ function badgeFor(monitor) {
30535
+ if (monitor.states.length === 0) return {
30536
+ text: "Not armed",
30537
+ tone: "unknown"
30538
+ };
30539
+ if (monitor.latched) return {
30540
+ text: "Changed",
30541
+ tone: "alarm"
30542
+ };
30543
+ if (monitor.suspendedCondition !== null) return {
30544
+ text: "Paused",
30545
+ tone: "unknown"
30546
+ };
30547
+ if (monitor.verdict === "unknown") return {
30548
+ text: "Can’t tell",
30549
+ tone: "unknown"
30550
+ };
30551
+ return monitor.verdict === "diverged" ? {
30552
+ text: "Changed",
30553
+ tone: "alarm"
30554
+ } : {
30555
+ text: "Normal",
30556
+ tone: "ok"
30557
+ };
30558
+ }
30559
+ /** A rect covering most of the frame is dominated by weather, sky and parked
30560
+ * cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
30561
+ * failure is silent INSENSITIVITY, which is the worst kind — hence a warning
30562
+ * rather than a refusal. */
30563
+ var ROI_AREA_WARN_FRACTION = .4;
30564
+ function roiTooLarge(roi) {
30565
+ return roi.width * roi.height > ROI_AREA_WARN_FRACTION;
30566
+ }
30567
+ function humanDuration(ms) {
30568
+ const s = Math.floor(ms / 1e3);
30569
+ if (s < 60) return `${s}s`;
30570
+ const m = Math.floor(s / 60);
30571
+ if (m < 60) return `${m}m`;
30572
+ const h = Math.floor(m / 60);
30573
+ if (h < 48) return `${h}h`;
30574
+ return `${Math.floor(h / 24)}d`;
30292
30575
  }
30293
30576
  //#endregion
30294
- //#region src/composites/data-table.tsx
30577
+ //#region src/composites/code-block.tsx
30578
+ function CodeBlock({ children, maxHeight = 300, className }) {
30579
+ const [copied, setCopied] = useState(false);
30580
+ const handleCopy = useCallback(() => {
30581
+ navigator.clipboard.writeText(children).then(() => {
30582
+ setCopied(true);
30583
+ setTimeout(() => setCopied(false), 2e3);
30584
+ });
30585
+ }, [children]);
30586
+ return /* @__PURE__ */ jsxs("div", {
30587
+ className: cn("relative group", className),
30588
+ children: [/* @__PURE__ */ jsx(ScrollArea, {
30589
+ style: { maxHeight },
30590
+ children: /* @__PURE__ */ jsx("pre", {
30591
+ className: "font-mono text-xs bg-surface p-3 rounded-md border border-border-subtle",
30592
+ children: /* @__PURE__ */ jsx("code", { children })
30593
+ })
30594
+ }), /* @__PURE__ */ jsx("div", {
30595
+ className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",
30596
+ children: /* @__PURE__ */ jsx(IconButton, {
30597
+ icon: copied ? Check : Copy,
30598
+ "aria-label": "Copy code",
30599
+ variant: "ghost",
30600
+ size: "sm",
30601
+ onClick: handleCopy
30602
+ })
30603
+ })]
30604
+ });
30605
+ }
30606
+ //#endregion
30607
+ //#region src/composites/confirm-action-button.tsx
30295
30608
  /**
30296
- * DataTable — shared responsive table for admin pages.
30297
- *
30298
- * Recipes baked in:
30299
- * - `overflow-x-auto` wrapper so narrow viewports don't clip the
30300
- * right edge (the rounded border survives the scroll).
30301
- * - `min-w-[Npx]` on the inner table so columns don't squash below
30302
- * readable width before the scroll kicks in.
30303
- * - `whitespace-nowrap` on headers — column titles never wrap to
30304
- * two lines (`AUTO-KILL` was the regression that triggered this).
30305
- * - Themed `bg-surface` + `border-border` so contrast works in both
30306
- * light and dark themes.
30307
- *
30308
- * Column model: caller supplies `columns: DataColumn<T>[]` and
30309
- * `rows: T[]`. Each column declares `header`, optional `align`,
30310
- * optional `nowrap`, and a `render(row) => ReactNode` that produces
30311
- * the cell content.
30312
- *
30313
- * <DataTable
30314
- * columns={[
30315
- * { key: 'username', header: 'Username', render: (u) => u.username },
30316
- * { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
30317
- * { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
30318
- * ]}
30319
- * rows={users}
30320
- * minWidthPx={560}
30321
- * />
30322
- *
30323
- * If finer header markup is needed, pass `headerRender` instead of
30324
- * `header` (e.g. for a sortable click affordance).
30325
- *
30326
- * ── Narrow viewports ──────────────────────────────────────────────────────
30327
- * The table stays a table. The wrapper is `overflow-x-auto`, so a phone
30328
- * scrolls the columns sideways INSIDE the table — the page body never moves
30329
- * sideways, and the cells stay one line (`whitespace-nowrap`). That is the
30330
- * default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
30331
- * `auto` still applies the old column-count rule for a surface that wants it.
30332
- * See `resolveTableLayout`.
30333
- *
30334
- * ── Scrolling the BODY, not the page ──────────────────────────────────────
30335
- * `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
30336
- * with the column header pinned. It is ONE prop, not two, because "this list
30337
- * scrolls internally" and "its header stays visible" are the same decision: a
30338
- * capped table whose header scrolls away is a list of unlabelled cells.
30609
+ * ConfirmActionButton — destructive-action button with a modal confirm.
30339
30610
  *
30340
- * Use it wherever the row count is open-ended (rules, targets, delivery
30341
- * history) so the page keeps its own header, filters and actions in view while
30342
- * the operator moves through the rows.
30343
- */
30344
- /**
30345
- * The default cap for an internally-scrolling table.
30611
+ * Two-step UX for any operation operators shouldn't trigger by accident:
30612
+ * reboot device, restart addon, regenerate token, etc. The trigger is a
30613
+ * normal Button that opens a Dialog; confirming runs the async action,
30614
+ * the trigger spinner-disables itself for the duration, and the dialog
30615
+ * closes on success. Errors surface inline at the dialog's bottom.
30346
30616
  *
30347
- * `60vh` leaves the page header, its filters and its actions on screen at
30348
- * every viewport while still showing ~10 rows — and it is a fraction, so it
30349
- * does not need a breakpoint of its own.
30617
+ * Generic on `TResult` so callers don't have to discard the return
30618
+ * value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
30619
+ * for reboot, RefreshCw for restart).
30350
30620
  */
30351
- var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
30352
- var ALIGN_CLASS = {
30353
- left: "text-left",
30354
- right: "text-right",
30355
- center: "text-center"
30356
- };
30357
- function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "scroll", maxBodyHeight, defaultSort }) {
30358
- const isNarrow = useIsMobile();
30359
- const [sort, setSort] = useState(defaultSort ?? null);
30360
- const layout = resolveTableLayout({
30361
- mode: mobileMode,
30362
- columnCount: columns.length,
30363
- isNarrow
30364
- });
30365
- const scrolls = maxBodyHeight !== void 0;
30366
- const sortedColumn = useMemo(() => sort === null ? void 0 : columns.find((col) => col.key === sort.columnKey && col.sortValue !== void 0), [columns, sort]);
30367
- const sortValue = sortedColumn?.sortValue;
30368
- const displayRows = useMemo(() => sortValue === void 0 ? rows : sortRowsByColumn(rows, sort, sortValue), [
30369
- rows,
30370
- sort,
30371
- sortValue
30372
- ]);
30373
- const activeSort = sortedColumn === void 0 ? null : sort;
30374
- if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ jsx("div", {
30375
- className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
30376
- children: emptyMessage
30377
- });
30378
- if (rows.length === 0) return null;
30379
- if (layout === "cards") return /* @__PURE__ */ jsx("div", {
30380
- className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
30381
- children: displayRows.map((row, rowIndex) => /* @__PURE__ */ jsx(DataTableCard, {
30382
- row,
30383
- rowIndex,
30384
- columns,
30385
- bordered,
30386
- onRowClick,
30387
- rowClassName
30388
- }, rowKey ? rowKey(row, rowIndex) : rowIndex))
30389
- });
30390
- return /* @__PURE__ */ jsx("div", {
30391
- className: [
30392
- "rounded-lg",
30393
- bordered ? "border border-border" : "",
30394
- "bg-surface overflow-x-auto",
30395
- scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
30396
- className ?? ""
30397
- ].filter(Boolean).join(" "),
30398
- children: /* @__PURE__ */ jsxs("table", {
30399
- className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
30400
- style: { minWidth: `${minWidthPx}px` },
30401
- children: [/* @__PURE__ */ jsx("thead", {
30402
- className: "bg-surface",
30403
- children: /* @__PURE__ */ jsx("tr", { children: columns.map((col) => {
30404
- const align = col.align ?? "left";
30405
- const nowrap = col.nowrap ?? true;
30406
- const sortable = col.sortValue !== void 0;
30407
- const isSorted = activeSort !== null && activeSort.columnKey === col.key;
30408
- return /* @__PURE__ */ jsx("th", {
30409
- "aria-sort": sortable ? ariaSortForColumn(activeSort, col.key) : void 0,
30410
- title: sortable ? void 0 : col.notSortableReason,
30411
- className: [
30412
- "px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
30413
- scrolls ? "sticky top-0 z-10" : "",
30414
- ALIGN_CLASS[align],
30415
- nowrap ? "whitespace-nowrap" : "",
30416
- col.width ?? ""
30417
- ].filter(Boolean).join(" "),
30418
- children: sortable ? /* @__PURE__ */ jsx(SortHeaderButton, {
30419
- label: col.headerRender ? col.headerRender() : col.header,
30420
- active: isSorted,
30421
- dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
30422
- onSort: () => setSort((prev) => nextTableSort(prev, col.key))
30423
- }) : col.headerRender ? col.headerRender() : col.header
30424
- }, col.key);
30425
- }) })
30426
- }), /* @__PURE__ */ jsx("tbody", { children: displayRows.map((row, rowIndex) => {
30427
- const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
30428
- const extra = rowClassName?.(row, rowIndex) ?? "";
30429
- const interactive = onRowClick !== void 0;
30430
- return /* @__PURE__ */ jsx("tr", {
30431
- onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
30432
- className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
30433
- children: columns.map((col) => {
30434
- const align = col.align ?? "left";
30435
- const nowrap = col.nowrap ?? true;
30436
- return /* @__PURE__ */ jsx("td", {
30437
- className: [
30438
- "px-3 py-2 text-foreground border-b border-border",
30439
- ALIGN_CLASS[align],
30440
- nowrap ? "whitespace-nowrap" : ""
30441
- ].filter(Boolean).join(" "),
30442
- children: col.render(row, rowIndex)
30443
- }, col.key);
30444
- })
30445
- }, key);
30446
- }) })]
30447
- })
30448
- });
30621
+ function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
30622
+ const [open, setOpen] = useState(false);
30623
+ const [running, setRunning] = useState(false);
30624
+ const [error, setError] = useState(null);
30625
+ const handleConfirm = async () => {
30626
+ setError(null);
30627
+ setRunning(true);
30628
+ try {
30629
+ const result = await action();
30630
+ onSuccess?.(result);
30631
+ setOpen(false);
30632
+ } catch (err) {
30633
+ setError(err instanceof Error ? err.message : String(err));
30634
+ } finally {
30635
+ setRunning(false);
30636
+ }
30637
+ };
30638
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
30639
+ type: "button",
30640
+ variant: triggerVariant,
30641
+ size,
30642
+ disabled,
30643
+ onClick: () => {
30644
+ setError(null);
30645
+ setOpen(true);
30646
+ },
30647
+ className,
30648
+ children: [Icon && /* @__PURE__ */ jsx(Icon, { className: "h-3.5 w-3.5" }), label]
30649
+ }), /* @__PURE__ */ jsx(Dialog, {
30650
+ open,
30651
+ onOpenChange: (next) => !running && setOpen(next),
30652
+ children: /* @__PURE__ */ jsxs(DialogContent, { children: [
30653
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [/* @__PURE__ */ jsxs(DialogTitle, {
30654
+ className: "flex items-center gap-2",
30655
+ children: [/* @__PURE__ */ jsx(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
30656
+ }), typeof description === "string" ? /* @__PURE__ */ jsx(DialogDescription, { children: description }) : description] }),
30657
+ error && /* @__PURE__ */ jsx("div", {
30658
+ className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
30659
+ children: error
30660
+ }),
30661
+ /* @__PURE__ */ jsxs(DialogFooter, { children: [/* @__PURE__ */ jsx(Button, {
30662
+ type: "button",
30663
+ variant: "ghost",
30664
+ size,
30665
+ disabled: running,
30666
+ onClick: () => setOpen(false),
30667
+ children: "Cancel"
30668
+ }), /* @__PURE__ */ jsxs(Button, {
30669
+ type: "button",
30670
+ variant: confirmVariant,
30671
+ size,
30672
+ disabled: running,
30673
+ onClick: () => {
30674
+ handleConfirm();
30675
+ },
30676
+ children: [running && /* @__PURE__ */ jsx(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
30677
+ })] })
30678
+ ] })
30679
+ })] });
30449
30680
  }
30681
+ //#endregion
30682
+ //#region src/composites/copy-button.tsx
30450
30683
  /**
30451
- * One row as a card. Columns that carry a plain-text `header` become labelled
30452
- * `SettingRow`s — the label is the column title and the value gets the full
30453
- * card width. Columns with no text header (an actions column, a
30454
- * `headerRender`-only column) have no label to show, so they render as a
30455
- * full-width strip at the foot of the card.
30684
+ * `CopyButton` — a compact button that copies a string `value` to the
30685
+ * clipboard via `navigator.clipboard.writeText`, showing a brief
30686
+ * "copied" check state. Addon-agnostic; used wherever a secret / URL
30687
+ * needs a one-click copy affordance (export setup panels, etc.).
30456
30688
  */
30457
- function DataTableCard({ row, rowIndex, columns, bordered, onRowClick, rowClassName }) {
30458
- const labelled = columns.filter((col) => col.header !== void 0 && col.header !== "");
30459
- const unlabelled = columns.filter((col) => col.header === void 0 || col.header === "");
30460
- const interactive = onRowClick !== void 0;
30461
- const extra = rowClassName?.(row, rowIndex) ?? "";
30462
- return /* @__PURE__ */ jsxs("div", {
30463
- "data-data-table-card": "",
30464
- onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
30465
- className: [
30466
- "rounded-lg bg-surface px-3 py-1.5 divide-y divide-border-subtle",
30467
- bordered ? "border border-border" : "",
30468
- interactive ? "cursor-pointer hover:bg-primary/5" : "",
30469
- extra
30470
- ].filter(Boolean).join(" "),
30471
- children: [labelled.map((col) => /* @__PURE__ */ jsx(SettingRow, {
30472
- label: col.header,
30473
- children: col.render(row, rowIndex)
30474
- }, col.key)), unlabelled.length > 0 && /* @__PURE__ */ jsx("div", {
30475
- className: "flex flex-wrap items-center justify-end gap-2 py-1.5",
30476
- children: unlabelled.map((col) => /* @__PURE__ */ jsx("div", { children: col.render(row, rowIndex) }, col.key))
30477
- })]
30689
+ var COPIED_RESET_MS = 2e3;
30690
+ function CopyButton({ value, label, srLabel, className, disabled }) {
30691
+ const [copied, setCopied] = useState(false);
30692
+ const handleCopy = useCallback(() => {
30693
+ if (!value) return;
30694
+ navigator.clipboard.writeText(value).then(() => {
30695
+ setCopied(true);
30696
+ setTimeout(() => setCopied(false), COPIED_RESET_MS);
30697
+ });
30698
+ }, [value]);
30699
+ return /* @__PURE__ */ jsxs(Button, {
30700
+ size: "sm",
30701
+ variant: "ghost",
30702
+ type: "button",
30703
+ disabled: disabled || value.length === 0,
30704
+ onClick: handleCopy,
30705
+ className: cn(className),
30706
+ "aria-label": copied ? "Copied" : `Copy ${srLabel ?? label ?? "value"}`,
30707
+ children: [copied ? /* @__PURE__ */ jsx(Check, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsx(Copy, { className: "h-3.5 w-3.5" }), label ? /* @__PURE__ */ jsx("span", {
30708
+ className: "ml-1",
30709
+ children: copied ? "Copied" : label
30710
+ }) : null]
30478
30711
  });
30479
30712
  }
30480
30713
  //#endregion
@@ -46532,99 +46765,6 @@ function fuzzyMatch(query, target) {
46532
46765
  return normalizeForSearch(target).includes(nq);
46533
46766
  }
46534
46767
  //#endregion
46535
- //#region src/composites/device-list/pagination.tsx
46536
- var NEIGHBORS = 2;
46537
- function computePageButtons(currentPage, totalPages) {
46538
- if (totalPages <= 1) return [{
46539
- kind: "page",
46540
- n: 1
46541
- }];
46542
- if (totalPages <= 7) {
46543
- const all = [];
46544
- for (let i = 1; i <= totalPages; i++) all.push({
46545
- kind: "page",
46546
- n: i
46547
- });
46548
- return all;
46549
- }
46550
- const result = [];
46551
- const leftBound = Math.max(2, currentPage - NEIGHBORS);
46552
- const rightBound = Math.min(totalPages - 1, currentPage + NEIGHBORS);
46553
- result.push({
46554
- kind: "page",
46555
- n: 1
46556
- });
46557
- if (leftBound > 2) result.push({
46558
- kind: "ellipsis",
46559
- side: "left"
46560
- });
46561
- for (let i = leftBound; i <= rightBound; i++) result.push({
46562
- kind: "page",
46563
- n: i
46564
- });
46565
- if (rightBound < totalPages - 1) result.push({
46566
- kind: "ellipsis",
46567
- side: "right"
46568
- });
46569
- result.push({
46570
- kind: "page",
46571
- n: totalPages
46572
- });
46573
- return result;
46574
- }
46575
- function Pagination({ currentPage, totalItems, pageSize, onPageChange, itemNoun = "devices" }) {
46576
- if (totalItems <= pageSize) return null;
46577
- const totalPages = Math.ceil(totalItems / pageSize);
46578
- const startIdx = (currentPage - 1) * pageSize + 1;
46579
- const endIdx = Math.min(currentPage * pageSize, totalItems);
46580
- const buttons = computePageButtons(currentPage, totalPages);
46581
- return /* @__PURE__ */ jsxs("div", {
46582
- className: "flex items-center justify-between gap-4 flex-wrap pt-2 text-[11px] text-foreground-subtle",
46583
- children: [/* @__PURE__ */ jsxs("span", { children: [
46584
- "Showing ",
46585
- startIdx,
46586
- "–",
46587
- endIdx,
46588
- " of ",
46589
- totalItems,
46590
- " ",
46591
- itemNoun
46592
- ] }), /* @__PURE__ */ jsxs("span", {
46593
- className: "inline-flex items-center gap-1",
46594
- children: [
46595
- /* @__PURE__ */ jsx("button", {
46596
- type: "button",
46597
- "aria-label": "Previous page",
46598
- disabled: currentPage === 1,
46599
- onClick: () => onPageChange(currentPage - 1),
46600
- className: cn("inline-flex items-center justify-center h-7 w-7 rounded border border-border bg-surface text-foreground-subtle", "hover:text-foreground hover:bg-surface-hover transition-colors", "disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface"),
46601
- children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-3 w-3" })
46602
- }),
46603
- buttons.map((b, i) => {
46604
- if (b.kind === "ellipsis") return /* @__PURE__ */ jsx("span", {
46605
- className: "px-1 text-foreground-subtle/60 select-none",
46606
- children: "…"
46607
- }, `e-${i}`);
46608
- return /* @__PURE__ */ jsx("button", {
46609
- type: "button",
46610
- onClick: () => onPageChange(b.n),
46611
- className: cn("inline-flex items-center justify-center h-7 min-w-7 px-1.5 rounded border text-[10.5px] font-medium transition-colors", b.n === currentPage ? "border-primary/40 bg-primary/10 text-primary" : "border-border bg-surface text-foreground-subtle hover:text-foreground hover:bg-surface-hover"),
46612
- children: b.n
46613
- }, `p-${b.n}`);
46614
- }),
46615
- /* @__PURE__ */ jsx("button", {
46616
- type: "button",
46617
- "aria-label": "Next page",
46618
- disabled: currentPage === totalPages,
46619
- onClick: () => onPageChange(currentPage + 1),
46620
- className: cn("inline-flex items-center justify-center h-7 w-7 rounded border border-border bg-surface text-foreground-subtle", "hover:text-foreground hover:bg-surface-hover transition-colors", "disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface"),
46621
- children: /* @__PURE__ */ jsx(ChevronRight, { className: "h-3 w-3" })
46622
- })
46623
- ]
46624
- })]
46625
- });
46626
- }
46627
- //#endregion
46628
46768
  //#region src/composites/device-list/multi-select.tsx
46629
46769
  /**
46630
46770
  * Compact multi-select dropdown — a single chip-sized trigger ("Label · N")