@camstack/ui-library 1.2.190 → 1.2.192

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1191,6 +1191,30 @@ var CircleX = createLucideIcon("circle-x", [
1191
1191
  * This source code is licensed under the ISC license.
1192
1192
  * See the LICENSE file in the root directory of this source tree.
1193
1193
  */
1194
+ var Clapperboard = createLucideIcon("clapperboard", [
1195
+ ["path", {
1196
+ d: "m12.296 3.464 3.02 3.956",
1197
+ key: "qash78"
1198
+ }],
1199
+ ["path", {
1200
+ 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",
1201
+ key: "1h7j8b"
1202
+ }],
1203
+ ["path", {
1204
+ d: "M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
1205
+ key: "4lm6w1"
1206
+ }],
1207
+ ["path", {
1208
+ d: "m6.18 5.276 3.1 3.899",
1209
+ key: "zjj9t3"
1210
+ }]
1211
+ ]);
1212
+ /**
1213
+ * @license lucide-react v0.576.0 - ISC
1214
+ *
1215
+ * This source code is licensed under the ISC license.
1216
+ * See the LICENSE file in the root directory of this source tree.
1217
+ */
1194
1218
  var Clock = createLucideIcon("clock", [["circle", {
1195
1219
  cx: "12",
1196
1220
  cy: "12",
@@ -6383,6 +6407,8 @@ var usePipelineAnalyticsGetTrackMedia = trpc.pipelineAnalytics.getTrackMedia.use
6383
6407
  var usePipelineAnalyticsListTrackMedia = trpc.pipelineAnalytics.listTrackMedia.useQuery;
6384
6408
  /** Generated alias around `trpc.pipelineAnalytics.listEventMedia.useQuery`. */
6385
6409
  var usePipelineAnalyticsListEventMedia = trpc.pipelineAnalytics.listEventMedia.useQuery;
6410
+ /** Generated alias around `trpc.pipelineAnalytics.ownersWithMedia.useQuery`. */
6411
+ var usePipelineAnalyticsOwnersWithMedia = trpc.pipelineAnalytics.ownersWithMedia.useQuery;
6386
6412
  /** Generated alias around `trpc.pipelineAnalytics.searchObjectEvents.useQuery`. */
6387
6413
  var usePipelineAnalyticsSearchObjectEvents = trpc.pipelineAnalytics.searchObjectEvents.useQuery;
6388
6414
  /** Generated alias around `trpc.pipelineAnalytics.wipeObjectEmbeddings.useMutation`. */
@@ -17605,334 +17631,1630 @@ function useCapabilityPresence(capabilityName) {
17605
17631
  return classifyCapability(query.data);
17606
17632
  }
17607
17633
  //#endregion
17608
- //#region src/contexts/vod-playback.tsx
17634
+ //#region ../../node_modules/class-variance-authority/dist/index.mjs
17609
17635
  /**
17610
- * VodPlayback — shared context coordinating recorded (VOD) playback between a
17611
- * widget/section (the Recording panel) and the hero StreamPanel.
17636
+ * Copyright 2022 Joe Bell. All rights reserved.
17612
17637
  *
17613
- * A CONTROLLER (the Recording widget) calls `play({ url, label })`; the hero
17614
- * StreamPanel CONSUMES `request` and switches into VOD mode (HLS playback
17615
- * through the hub data-plane) instead of the live WebRTC stream. `clear()`
17616
- * returns the hero to live.
17638
+ * This file is licensed to you under the Apache License, Version 2.0
17639
+ * (the "License"); you may not use this file except in compliance with the
17640
+ * License. You may obtain a copy of the License at
17617
17641
  *
17618
- * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
17619
- * the Module-Federation boundary: an addon-shipped widget consuming
17620
- * `useVodPlayback()` resolves the SAME provider instance the host mounted —
17621
- * the same mechanism `player-overlays` / `zone-editing` use to let federated
17622
- * widgets (zone-editor, privacy-mask) drive the live player. Consumers used
17623
- * outside a provider get the inert default (no-op), so a StreamPanel mounted
17624
- * on a page without the Recording widget stays live-only without crashing.
17625
- */
17626
- var VodPlaybackContext = createSharedContext("camstack:vod-playback", {
17627
- request: null,
17628
- play: () => {},
17629
- clear: () => {}
17642
+ * http://www.apache.org/licenses/LICENSE-2.0
17643
+ *
17644
+ * Unless required by applicable law or agreed to in writing, software
17645
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
17646
+ * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the
17647
+ * License for the specific language governing permissions and limitations under
17648
+ * the License.
17649
+ */ var falsyToString = (value) => typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value;
17650
+ var cx = clsx;
17651
+ var cva = (base, config) => (props) => {
17652
+ var _config_compoundVariants;
17653
+ 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);
17654
+ const { variants, defaultVariants } = config;
17655
+ const getVariantClassNames = Object.keys(variants).map((variant) => {
17656
+ const variantProp = props === null || props === void 0 ? void 0 : props[variant];
17657
+ const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];
17658
+ if (variantProp === null) return null;
17659
+ const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);
17660
+ return variants[variant][variantKey];
17661
+ });
17662
+ const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param) => {
17663
+ let [key, value] = param;
17664
+ if (value === void 0) return acc;
17665
+ acc[key] = value;
17666
+ return acc;
17667
+ }, {});
17668
+ 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) => {
17669
+ let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;
17670
+ return Object.entries(compoundVariantOptions).every((param) => {
17671
+ let [key, value] = param;
17672
+ return Array.isArray(value) ? value.includes({
17673
+ ...defaultVariants,
17674
+ ...propsWithoutUndefined
17675
+ }[key]) : {
17676
+ ...defaultVariants,
17677
+ ...propsWithoutUndefined
17678
+ }[key] === value;
17679
+ }) ? [
17680
+ ...acc,
17681
+ cvClass,
17682
+ cvClassName
17683
+ ] : acc;
17684
+ }, []), props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
17685
+ };
17686
+ //#endregion
17687
+ //#region src/primitives/badge.tsx
17688
+ var badgeVariants = cva("inline-flex items-center rounded-full text-xs font-medium px-2 py-0.5", {
17689
+ variants: {
17690
+ variant: {
17691
+ default: "bg-surface-hover text-foreground",
17692
+ success: "bg-success/15 text-success",
17693
+ warning: "bg-warning/15 text-warning",
17694
+ danger: "bg-danger/15 text-danger",
17695
+ info: "bg-info/15 text-info"
17696
+ },
17697
+ styleVariant: {
17698
+ solid: "",
17699
+ outline: "border bg-transparent"
17700
+ }
17701
+ },
17702
+ defaultVariants: {
17703
+ variant: "default",
17704
+ styleVariant: "solid"
17705
+ }
17630
17706
  });
17631
- function VodPlaybackProvider({ children }) {
17632
- const [request, setRequest] = (0, react$1.useState)(null);
17633
- const value = (0, react$1.useMemo)(() => ({
17634
- request,
17635
- play: (req) => setRequest(req),
17636
- clear: () => setRequest(null)
17637
- }), [request]);
17638
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VodPlaybackContext.Provider, {
17639
- value,
17640
- children
17707
+ var Badge = (0, react$1.forwardRef)(({ className, variant, style, ...props }, ref) => {
17708
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17709
+ ref,
17710
+ className: cn(badgeVariants({
17711
+ variant,
17712
+ styleVariant: style
17713
+ }), className),
17714
+ ...props
17641
17715
  });
17642
- }
17643
- /** @returns the VOD playback controller (inert no-op outside a provider). */
17644
- function useVodPlayback() {
17645
- return (0, react$1.useContext)(VodPlaybackContext);
17646
- }
17716
+ });
17717
+ Badge.displayName = "Badge";
17647
17718
  //#endregion
17648
- //#region src/composites/clips/ClipPanel.tsx
17719
+ //#region src/primitives/button.tsx
17720
+ var buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
17721
+ variants: {
17722
+ variant: {
17723
+ primary: "bg-primary text-primary-foreground hover:bg-primary/90",
17724
+ secondary: "bg-surface text-foreground hover:bg-surface-hover",
17725
+ ghost: "hover:bg-surface-hover text-foreground-muted",
17726
+ danger: "bg-danger text-white hover:bg-danger/90",
17727
+ outline: "border border-border bg-transparent text-foreground hover:bg-surface-hover"
17728
+ },
17729
+ size: {
17730
+ sm: "h-7 px-2.5 text-xs gap-1.5",
17731
+ md: "h-8 px-3 text-sm gap-2",
17732
+ lg: "h-9 px-4 text-sm gap-2"
17733
+ }
17734
+ },
17735
+ defaultVariants: {
17736
+ variant: "primary",
17737
+ size: "sm"
17738
+ }
17739
+ });
17740
+ var Button = (0, react$1.forwardRef)(({ className, variant, size, ...props }, ref) => {
17741
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17742
+ ref,
17743
+ className: cn(buttonVariants({
17744
+ variant,
17745
+ size
17746
+ }), className),
17747
+ ...props
17748
+ });
17749
+ });
17750
+ Button.displayName = "Button";
17751
+ //#endregion
17752
+ //#region src/primitives/select.tsx
17649
17753
  /**
17650
- * The selected clip — what it is, how it plays, and how it is saved.
17651
- *
17652
- * **No second player.** `getClipPlayback` answers `{ playbackUrl, format,
17653
- * served }` and the panel hands it to the SAME hero the recordings timeline
17654
- * drives: `vod.play({ url, format })` → `StreamPanel playbackUrl/playbackFormat`
17655
- * → `HlsVideo`'s `mp4` branch (`video.src`, no hls.js). Building a clip player
17656
- * here is the thing D549 decision 22 exists to refuse.
17657
- *
17658
- * **`format` travels.** An MP4 handed to hls.js is parsed as a manifest and
17659
- * fails, so the format the provider named is forwarded verbatim and never
17660
- * defaulted here.
17754
+ * A native `<select>` styled to match the rest of the field set.
17661
17755
  *
17662
- * **`served` is shown.** A clip that holds only one twin answers with the one
17663
- * it has, and the surface SAYS so — a missing main twin must never be served
17664
- * silently as if it were the asked-for quality (D549 decision 15). Both ids
17665
- * are already on the row, so changing quality re-asks the hub and never
17666
- * re-searches the camera.
17756
+ * `appearance-none` plus an explicit `bg-surface` / `text-foreground` is what
17757
+ * makes it match — and it is also what USED to make a disabled one a lie. A
17758
+ * browser greys a disabled control by rendering its own background and text
17759
+ * colour; all three of those are overridden here, so a `disabled` dropdown
17760
+ * looked exactly like a live one and simply ignored the click. That is the
17761
+ * whole of the "the language selector does not open" report of 2026-08-15:
17762
+ * the AI section's switch was off, every control under it was correctly inert,
17763
+ * and nothing on screen said so.
17667
17764
  *
17668
- * **Nothing is appended to the URL.** The route is `access:'authenticated'`
17669
- * and the session cookie authenticates it (D549 decision 22); the same URL is
17670
- * the download.
17765
+ * `disabled:` classes belong HERE and not on each call site: there are dozens
17766
+ * of `<Select disabled>` in this app and the one that gets forgotten is the one
17767
+ * that generates the ticket. Guarded by `disabled-affordance.test.tsx`.
17671
17768
  */
17672
- var PROFILES = [
17673
- "low",
17674
- "mid",
17675
- "high"
17676
- ];
17677
- function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
17678
- const vod = useVodPlayback();
17679
- const playback = useVideoclipsGetClipPlayback({
17680
- deviceId,
17681
- clipId: clip.id,
17682
- profile
17683
- });
17684
- const data = playback.data;
17685
- const label = `${new Date(clip.timeRange.startMs).toLocaleString()} · ${clip.source}`;
17686
- (0, react$1.useEffect)(() => {
17687
- if (data === void 0) return;
17688
- vod.play({
17689
- url: hubPath(data.playbackUrl),
17690
- ...data.format === void 0 ? {} : { format: data.format },
17691
- label
17692
- });
17693
- }, [data, label]);
17769
+ var Select = (0, react$1.forwardRef)(({ className, options, disabled, ...props }, ref) => {
17694
17770
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17695
- className: "space-y-2 rounded-md border border-border p-3 text-sm",
17696
- children: [
17697
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17698
- className: "font-medium",
17699
- children: label
17700
- }),
17701
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17702
- className: "flex items-center gap-2",
17703
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17704
- className: "text-xs text-muted-foreground",
17705
- children: "Quality"
17706
- }), PROFILES.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17707
- type: "button",
17708
- "aria-pressed": option === profile,
17709
- onClick: () => onProfileChange(option),
17710
- className: `rounded px-2 py-0.5 text-xs ${option === profile ? "bg-primary text-primary-foreground" : "bg-muted"}`,
17711
- children: option
17712
- }, option))]
17713
- }),
17714
- playback.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17715
- role: "status",
17716
- children: "Preparing the clip — the file is fetched and muxed once…"
17717
- }) : playback.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17718
- role: "alert",
17719
- children: ["This clip could not be prepared: ", playback.error?.message ?? "no reason given"]
17720
- }) : data === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17721
- className: "space-y-1",
17722
- children: [data.served === void 0 || data.served === profile ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17723
- className: "text-xs text-amber-600 dark:text-amber-400",
17724
- children: [
17725
- "Served the ",
17726
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: data.served }),
17727
- " stream — this clip has no ",
17728
- profile,
17729
- " twin."
17730
- ]
17731
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
17732
- href: hubPath(data.playbackUrl),
17733
- download: true,
17734
- className: "text-xs underline",
17735
- children: "Download this clip"
17736
- })]
17737
- })
17738
- ]
17771
+ className: "relative w-full",
17772
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
17773
+ ref,
17774
+ disabled,
17775
+ 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),
17776
+ ...props,
17777
+ children: options.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17778
+ value: option.value,
17779
+ children: option.label
17780
+ }, option.value))
17781
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17782
+ className: cn("absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-foreground-muted", disabled === true ? "opacity-50" : ""),
17783
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3.5 w-3.5" })
17784
+ })]
17739
17785
  });
17740
- }
17786
+ });
17787
+ Select.displayName = "Select";
17741
17788
  //#endregion
17742
- //#region src/composites/clips/ClipSilentSources.tsx
17743
- function ClipSilentSources({ silent }) {
17744
- if (silent.length === 0) return null;
17745
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
17746
- className: "space-y-1 rounded-md border border-amber-500/40 bg-amber-500/5 p-2 text-xs",
17747
- children: silent.map((notice) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
17748
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17749
- className: "font-medium",
17750
- children: notice.label
17751
- }),
17752
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
17753
- className: "text-muted-foreground",
17754
- children: [
17755
- " (",
17756
- notice.source,
17757
- ")"
17758
- ]
17759
- }),
17760
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: notice.headline }),
17761
- notice.detail === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17762
- className: "text-muted-foreground break-words",
17763
- children: notice.detail
17764
- })
17765
- ] }, notice.source))
17789
+ //#region src/primitives/skeleton.tsx
17790
+ var Skeleton = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
17791
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17792
+ ref,
17793
+ className: cn("animate-pulse bg-surface-hover rounded-md h-4 w-full", className),
17794
+ ...props
17766
17795
  });
17767
- }
17796
+ });
17797
+ Skeleton.displayName = "Skeleton";
17768
17798
  //#endregion
17769
- //#region src/composites/clips/ClipSourceRow.tsx
17770
- var TONE_CLASS = {
17771
- ok: "text-emerald-600 dark:text-emerald-400",
17772
- warn: "text-amber-600 dark:text-amber-400",
17773
- error: "text-rose-600 dark:text-rose-400"
17774
- };
17775
- function ClipSourceRow({ notice, catalogAge, checked, onToggle }) {
17776
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
17777
- className: "flex items-start gap-2 py-1.5",
17778
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17779
- type: "checkbox",
17780
- className: "mt-1",
17781
- checked,
17782
- "aria-label": `Show ${notice.label}`,
17783
- onChange: (e) => onToggle(notice.source, e.target.checked)
17784
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17785
- className: "min-w-0",
17786
- children: [
17787
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17788
- className: "flex flex-wrap items-baseline gap-x-2",
17789
- children: [
17790
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17791
- className: "font-medium",
17792
- children: notice.label
17793
- }),
17794
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17795
- className: "text-xs text-muted-foreground",
17796
- children: notice.source
17797
- }),
17798
- catalogAge === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
17799
- className: "text-xs text-muted-foreground",
17800
- children: ["read ", catalogAge]
17801
- })
17802
- ]
17803
- }),
17804
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17805
- className: `text-xs ${TONE_CLASS[notice.tone]}`,
17806
- children: notice.headline
17807
- }),
17808
- notice.detail === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17809
- className: "text-xs text-muted-foreground break-words",
17810
- children: notice.detail
17811
- })
17812
- ]
17813
- })]
17814
- });
17799
+ //#region src/composites/cap-settings/recording-timeline-model.ts
17800
+ /** Tie-break precedence for a bucket's dominant kind (first wins on equal count). */
17801
+ var KIND_PRECEDENCE = [
17802
+ "object",
17803
+ "motion",
17804
+ "audio"
17805
+ ];
17806
+ /**
17807
+ * Convert server-side density rows into `DensityBucket[]` for the timeline
17808
+ * heatmap. Rows with total === 0 are skipped (defensive; server already omits
17809
+ * them). Returns a sparse, ascending-by-index array — EventHeatmap positions
17810
+ * each bar by `b.startMs` rather than array index, so gaps are fine.
17811
+ */
17812
+ function densityRowsToBuckets(rows, windowFrom, bucketMs) {
17813
+ const buckets = [];
17814
+ for (const row of rows) {
17815
+ const counts = {
17816
+ motion: row.motion,
17817
+ object: row.object,
17818
+ audio: row.audio
17819
+ };
17820
+ const total = counts.motion + counts.object + counts.audio;
17821
+ if (total === 0) continue;
17822
+ const index = Math.floor((row.bucketStart - windowFrom) / bucketMs);
17823
+ let dominant = null;
17824
+ let best = -1;
17825
+ for (const kind of KIND_PRECEDENCE) if (counts[kind] > best) {
17826
+ best = counts[kind];
17827
+ dominant = kind;
17828
+ }
17829
+ buckets.push({
17830
+ index,
17831
+ startMs: row.bucketStart,
17832
+ endMs: row.bucketStart + bucketMs,
17833
+ counts,
17834
+ total,
17835
+ dominant
17836
+ });
17837
+ }
17838
+ return buckets.toSorted((a, b) => a.index - b.index);
17839
+ }
17840
+ /** Distance from `ms` to the interval `[startMs, endMs]` (0 if inside). */
17841
+ function distanceToSpan(ms, span) {
17842
+ if (ms < span.startMs) return span.startMs - ms;
17843
+ if (ms > span.endMs) return ms - span.endMs;
17844
+ return 0;
17815
17845
  }
17816
- //#endregion
17817
- //#region src/composites/clips/clip-tile-media.ts
17818
17846
  /**
17819
- * Why no still exists, in words. Keyed by the provider's own vocabulary so a
17820
- * reason this table does not know still reaches the operator verbatim.
17847
+ * Resolve a playback window for a click at `clickMs` against recorded `spans`.
17848
+ * Inside a span → play from the click to that span's end. In a gap / outside →
17849
+ * snap to the WHOLE nearest span. No spans → null (nothing to play).
17850
+ * `spans` are expected ascending by start (as `mergeAvailabilitySpans` returns).
17821
17851
  */
17822
- var REFUSAL_TEXT = {
17823
- sleeping: "The camera is asleep — a still would cost a wake",
17824
- "camera-refused": "The camera refused the still request",
17825
- "no-keyframe": "No decodable frame inside this window",
17826
- unsupported: "This source cannot produce stills",
17827
- "queue-full": "The camera is busy — its still queue is full",
17828
- "no-catalog-row": "This clip is no longer in the catalog"
17829
- };
17830
- function refusal(reason) {
17831
- return {
17832
- kind: "unavailable",
17833
- reason,
17834
- text: REFUSAL_TEXT[reason] ?? `Refused: ${reason}`
17852
+ function seekTargetForSpans(clickMs, spans) {
17853
+ if (spans.length === 0) return null;
17854
+ const containing = spans.find((s) => clickMs >= s.startMs && clickMs <= s.endMs);
17855
+ if (containing) return {
17856
+ fromMs: clickMs,
17857
+ toMs: containing.endMs
17835
17858
  };
17859
+ let nearest = null;
17860
+ let bestDist = Infinity;
17861
+ for (const s of spans) {
17862
+ const d = distanceToSpan(clickMs, s);
17863
+ if (d < bestDist) {
17864
+ bestDist = d;
17865
+ nearest = s;
17866
+ }
17867
+ }
17868
+ return nearest ? {
17869
+ fromMs: nearest.startMs,
17870
+ toMs: nearest.endMs
17871
+ } : null;
17836
17872
  }
17837
- /** What to draw for one clip's tile. */
17838
- function resolveTileMedia(clip) {
17839
- if (clip.thumbnail !== void 0) return {
17840
- kind: "vouched",
17841
- url: clip.thumbnail
17842
- };
17843
- if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason);
17844
- if (clip.thumbnailMint !== void 0) return {
17845
- kind: "mint",
17846
- url: clip.thumbnailMint
17847
- };
17848
- return {
17849
- kind: "none",
17850
- text: "This clip carries no still and offered none"
17851
- };
17873
+ /**
17874
+ * Position `ms` inside the window `[fromMs, toMs]` as a clamped percentage
17875
+ * (0–100). A zero/negative-width window yields 0. Callers use this for `left:`.
17876
+ */
17877
+ function pctForMs(ms, fromMs, toMs) {
17878
+ const span = toMs - fromMs;
17879
+ if (span <= 0) return 0;
17880
+ const pct = (ms - fromMs) / span * 100;
17881
+ if (pct < 0) return 0;
17882
+ if (pct > 100) return 100;
17883
+ return pct;
17852
17884
  }
17853
17885
  /**
17854
- * A mint that answered 204 — latch its `x-camstack-reason` to this instant.
17855
- *
17856
- * The refusal is about NOW (a full queue, a sleeping camera), so it replaces
17857
- * the offer for as long as the tile is mounted and is not retried behind the
17858
- * operator's back. An unknown reason is shown raw rather than dropped: a
17859
- * firmware inventing a refusal must not become a blank tile.
17886
+ * Build a DayWindow for the given calendar `date` using local time.
17887
+ * For any day strictly in the past: `fromMs` = local midnight, `toMs` =
17888
+ * next local midnight (exactly 24 h).
17889
+ * For today (or any future day that contains `nowMs`): `toMs = nowMs` so the
17890
+ * window never extends into the future.
17860
17891
  */
17861
- function latchMintRefusal(reason) {
17862
- return refusal(reason);
17892
+ function dayWindow(date, nowMs) {
17893
+ const fromMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime();
17894
+ const nextMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
17895
+ return {
17896
+ fromMs,
17897
+ toMs: Math.max(fromMs, Math.min(nextMidnight, nowMs))
17898
+ };
17863
17899
  }
17864
- //#endregion
17865
- //#region src/composites/clips/clip-thumbnail-fetch.ts
17900
+ /** Available zoom levels in ms, from widest (24h) to narrowest (15 min). */
17901
+ var ZOOM_LEVELS = [
17902
+ 24 * 36e5,
17903
+ 12 * 36e5,
17904
+ 6 * 36e5,
17905
+ 36e5,
17906
+ 15 * 6e4
17907
+ ];
17866
17908
  /**
17867
- * Asking a `thumbnailMint` route for a still — and hearing its refusal.
17868
- *
17869
- * The route answers **200** with a JPEG or **204** with the reason in
17870
- * `x-camstack-reason` (`queue-full`, `sleeping`, `camera-refused`,
17871
- * `no-keyframe`, `no-catalog-row`). An `<img src>` cannot read that header:
17872
- * a 204 fires `onerror` with nothing to say, turning a named refusal into an
17873
- * anonymous broken tile. So a mint goes through `fetch`.
17874
- *
17875
- * **Nothing is appended to the URL.** Both data-plane routes are registered
17876
- * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
17877
- * the session cookie the admin UI sets at login, so the URL is never a
17878
- * credential (D549 decision 22) — it may sit in history or a `Referer` and
17879
- * open nothing.
17909
+ * Produce a `levelMs`-wide window whose RIGHT edge stays anchored at
17910
+ * `rightEdgeMs` (clamped within `dayBounds`), rather than centring on the view
17911
+ * midpoint. On today's timeline the view's right edge is "now", so zooming this
17912
+ * way keeps the most recent footage pinned to the right instead of drifting to
17913
+ * a midday range that no longer contains now. Panning then moves back/forward.
17880
17914
  *
17881
- * The refusal is about NOW and is LATCHED by the caller: a camera that just
17882
- * said "busy" is not asked again behind the operator's back.
17915
+ * Falls back to `dayBounds` when `levelMs` meets or exceeds the day span (can't
17916
+ * show a wider window than the day holds — e.g. early morning with little
17917
+ * elapsed time).
17883
17918
  */
17919
+ function zoomWindowAnchoredRight(dayBounds, levelMs, rightEdgeMs) {
17920
+ if (levelMs >= dayBounds.toMs - dayBounds.fromMs) return dayBounds;
17921
+ const toMs = Math.min(Math.max(rightEdgeMs, dayBounds.fromMs + levelMs), dayBounds.toMs);
17922
+ return {
17923
+ fromMs: toMs - levelMs,
17924
+ toMs
17925
+ };
17926
+ }
17884
17927
  /**
17885
- * Ask a mint route for one still.
17886
- *
17887
- * Never rejects: a thrown fetch is a refusal like any other, because a tile
17888
- * that throws is a tile that renders nothing and says nothing — the exact
17889
- * failure this module exists to prevent.
17928
+ * Shift `win` by `deltaMs`, preserving width, clamped within `dayBounds`.
17890
17929
  */
17891
- async function fetchMintedThumbnail(url, fetcher) {
17892
- let response;
17893
- try {
17894
- response = await fetcher(url, { credentials: "same-origin" });
17895
- } catch (err) {
17896
- return latchMintRefusal(err instanceof Error ? err.message : String(err));
17930
+ function panWindow(win, dayBounds, deltaMs) {
17931
+ const width = win.toMs - win.fromMs;
17932
+ let fromMs = win.fromMs + deltaMs;
17933
+ let toMs = win.toMs + deltaMs;
17934
+ if (fromMs < dayBounds.fromMs) {
17935
+ fromMs = dayBounds.fromMs;
17936
+ toMs = fromMs + width;
17897
17937
  }
17898
- if (response.status === 204) return latchMintRefusal(response.headers.get("x-camstack-reason") ?? "no reason given");
17899
- if (!response.ok) return latchMintRefusal(`HTTP ${String(response.status)}`);
17900
- try {
17901
- return {
17902
- kind: "image",
17903
- blob: await response.blob()
17904
- };
17905
- } catch (err) {
17906
- return latchMintRefusal(err instanceof Error ? err.message : String(err));
17938
+ if (toMs > dayBounds.toMs) {
17939
+ toMs = dayBounds.toMs;
17940
+ fromMs = toMs - width;
17907
17941
  }
17942
+ return {
17943
+ fromMs,
17944
+ toMs
17945
+ };
17908
17946
  }
17909
- //#endregion
17910
- //#region src/composites/clips/ClipTile.tsx
17947
+ /** Candidate bucket sizes in ms (coarsest first). */
17948
+ var BUCKET_CANDIDATES = [
17949
+ 15 * 6e4,
17950
+ 5 * 6e4,
17951
+ 6e4,
17952
+ 3e4,
17953
+ 15e3,
17954
+ 5e3
17955
+ ];
17911
17956
  /**
17912
- * One clip, as a tile.
17913
- *
17914
- * The picture is resolved by `resolveTileMedia` — vouched JPEG, an offer, or
17915
- * the reason there is none (D549 decision 5). An offer is fetched rather than
17916
- * dropped into an `<img src>`, because the route's **204 + x-camstack-reason**
17917
- * is invisible to an `<img>`: it fires `onerror` with nothing to say. The
17918
- * refusal is LATCHED for the life of the tile and never retried behind the
17919
- * operator's back.
17920
- *
17921
- * The row also carries what the camera itself said: its native types, the
17922
- * behaviours beside the class, which storages hold it, and — for a row that
17923
- * is an EVENT sitting inside no file — `playable: false` with its reason
17924
- * (measured: 1 of 29 hub events on 3628). Such a row is SHOWN, never dropped
17925
- * and never offered as playable-then-failing (D549 decision 19).
17957
+ * Choose a sensible heatmap bucket size for a visible span of `spanMs`,
17958
+ * targeting ~96-120 buckets. Always picks from `BUCKET_CANDIDATES`.
17926
17959
  */
17927
- function clockOf(ms) {
17928
- return new Date(ms).toLocaleTimeString(void 0, {
17929
- hour: "2-digit",
17960
+ function bucketMsForSpan(spanMs) {
17961
+ const targetBucket = spanMs / 100;
17962
+ for (const candidate of BUCKET_CANDIDATES) if (candidate <= targetBucket) return candidate;
17963
+ return BUCKET_CANDIDATES[BUCKET_CANDIDATES.length - 1] ?? 5e3;
17964
+ }
17965
+ /**
17966
+ * Produce `count` evenly-spaced axis ticks spanning [fromMs, toMs].
17967
+ * - `pct` is via pctForMs (first=0, last=100).
17968
+ * - `label` is a localised HH:MM clock string.
17969
+ */
17970
+ function axisTicks(fromMs, toMs, count) {
17971
+ if (count <= 1) return [{
17972
+ pct: 0,
17973
+ label: new Date(fromMs).toLocaleTimeString(void 0, {
17974
+ hour: "2-digit",
17975
+ minute: "2-digit"
17976
+ })
17977
+ }];
17978
+ const ticks = [];
17979
+ for (let i = 0; i < count; i++) {
17980
+ const ms = fromMs + i / (count - 1) * (toMs - fromMs);
17981
+ const pct = pctForMs(ms, fromMs, toMs);
17982
+ const label = new Date(ms).toLocaleTimeString(void 0, {
17983
+ hour: "2-digit",
17984
+ minute: "2-digit"
17985
+ });
17986
+ ticks.push({
17987
+ pct,
17988
+ label
17989
+ });
17990
+ }
17991
+ return ticks;
17992
+ }
17993
+ //#endregion
17994
+ //#region src/hooks/use-is-mobile.ts
17995
+ var MOBILE_QUERY = "(max-width: 767px)";
17996
+ /**
17997
+ * Mid breakpoint — below this width the sidebar collapses to an
17998
+ * icons-only rail to reclaim canvas space. Above it (and outside
17999
+ * mobile) the full sidebar renders. Threshold matches Tailwind `lg`
18000
+ * so sidebar fans out at the same point grids switch to multi-column.
18001
+ */
18002
+ var MID_QUERY = "(min-width: 768px) and (max-width: 1023px)";
18003
+ /**
18004
+ * `matchMedia` is universal in browsers but absent in bare DOM environments.
18005
+ * These hooks are called from shared primitives (`DataTable` picks its narrow
18006
+ * layout with one), so throwing here takes down the whole page that merely
18007
+ * rendered a table. Where the capability is missing, report the desktop
18008
+ * layout — a widescreen rendering is wrong-looking; a crashed page is gone.
18009
+ */
18010
+ function matchQuery(query) {
18011
+ if (typeof window.matchMedia !== "function") return null;
18012
+ return window.matchMedia(query);
18013
+ }
18014
+ function subscribeQuery(query) {
18015
+ return (callback) => {
18016
+ const mql = matchQuery(query);
18017
+ if (mql === null) return () => {};
18018
+ mql.addEventListener("change", callback);
18019
+ return () => mql.removeEventListener("change", callback);
18020
+ };
18021
+ }
18022
+ function getSnapshot(query) {
18023
+ return () => matchQuery(query)?.matches ?? false;
18024
+ }
18025
+ function getServerSnapshot() {
18026
+ return false;
18027
+ }
18028
+ function useIsMobile() {
18029
+ return (0, react$1.useSyncExternalStore)(subscribeQuery(MOBILE_QUERY), getSnapshot(MOBILE_QUERY), getServerSnapshot);
18030
+ }
18031
+ /**
18032
+ * True when the viewport is in the medium (`md`) Tailwind range —
18033
+ * 768px ≤ width < 1024px. Use to drive layouts that should pack denser
18034
+ * than full-desktop but more legibly than mobile (e.g. icons-only
18035
+ * sidebar rail, narrower stat tiles).
18036
+ */
18037
+ function useIsMidWidth() {
18038
+ return (0, react$1.useSyncExternalStore)(subscribeQuery(MID_QUERY), getSnapshot(MID_QUERY), getServerSnapshot);
18039
+ }
18040
+ //#endregion
18041
+ //#region src/composites/data-table-layout.ts
18042
+ /**
18043
+ * At this many columns and above, `auto` switches a narrow viewport to cards.
18044
+ * Three columns still fit a phone at a readable size; four do not.
18045
+ */
18046
+ var CARD_MODE_MIN_COLUMNS = 4;
18047
+ function resolveTableLayout({ mode, columnCount, isNarrow }) {
18048
+ if (!isNarrow) return "table";
18049
+ if (mode === "scroll") return "table";
18050
+ if (mode === "cards") return "cards";
18051
+ if (columnCount === 0) return "table";
18052
+ return columnCount >= 4 ? "cards" : "table";
18053
+ }
18054
+ //#endregion
18055
+ //#region src/lib/responsive.ts
18056
+ /**
18057
+ * Responsive design tokens — three-tier system.
18058
+ *
18059
+ * The admin-ui targets three width tiers; every component should pick
18060
+ * the right token from this file instead of hand-coding breakpoints.
18061
+ * Adopting the tokens uniformly is what makes the app feel consistent
18062
+ * across operator devices (mobile / tablet / desktop) and what unlocks
18063
+ * targeted tuning (e.g. shrink every label by 1px in one place).
18064
+ *
18065
+ * Tier definitions (Tailwind breakpoints):
18066
+ *
18067
+ * • **L1 — Compact** (`< 640px`, default = mobile portrait)
18068
+ * Single column, tight padding, smallest text, icon-only buttons
18069
+ * where labels can be inferred. The operator can still see every
18070
+ * stat / action — no horizontal scroll, no clipped text. The user
18071
+ * directive: "molta informazione in poco spazio quando la width è
18072
+ * bassa."
18073
+ *
18074
+ * • **L2 — Standard** (`sm` — `640–1023px`, tablet + small laptop)
18075
+ * 2-column grids, side panels become visible, button labels return.
18076
+ * Targets ~768px–1024px viewports — the most common operator sizes.
18077
+ *
18078
+ * • **L3 — Spacious** (`lg+` — `≥ 1024px`, desktop)
18079
+ * 4-column grids, generous padding, full-width header text,
18080
+ * side-by-side composite layouts. The operator has elbow room.
18081
+ *
18082
+ * Conventions:
18083
+ * - **Always start from L1** — the bare class is always the compact
18084
+ * variant, then `sm:` adds room at 640px, `lg:` at 1024px. No
18085
+ * desktop-first breakpoints; no max-width queries.
18086
+ * - **Token strings are static** — kept as `as const` so callers can
18087
+ * compose them via `cn()` / template literals without running a
18088
+ * class generator at runtime.
18089
+ * - **Pair the token with a comment** — the comment in the file
18090
+ * names every breakpoint it crosses, so a reader can predict the
18091
+ * layout without going to Tailwind docs.
18092
+ */
18093
+ /** Container padding for a card-style section (header + body). */
18094
+ var SECTION_CARD = "rounded-lg border border-border bg-surface overflow-hidden";
18095
+ /** Header strip inside a card (between border-b lines). */
18096
+ var SECTION_HEADER = "border-b border-border px-3 sm:px-4 py-1.5 sm:py-2";
18097
+ /** Body padding for a card-style section. */
18098
+ var SECTION_BODY = "p-2 sm:p-3 lg:p-4";
18099
+ /** Gap between cards in a vertical stack. */
18100
+ var STACK_GAP = "space-y-3 sm:space-y-4";
18101
+ /** Gap between cards in a grid (4 cells / row at lg). */
18102
+ var GRID_GAP = "gap-2 sm:gap-3";
18103
+ /** Row padding inside a divided list (e.g. InfoRow). */
18104
+ var LIST_ROW = "px-3 sm:px-4 py-1.5 sm:py-2";
18105
+ /** 1-up at L1, 2-up at L2, 4-up at L3 — quick-stat row. */
18106
+ var GRID_QUICK_STATS = "grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4";
18107
+ /** 1-up at L1, 2-up at L2+ — paired panels (motion + detection rules). */
18108
+ var GRID_PAIRED = "grid grid-cols-1 lg:grid-cols-2";
18109
+ /** Side-by-side layout that stacks below md (canvas + side panel). */
18110
+ var SPLIT_PANEL_OUTER = "flex flex-col-reverse md:flex-row";
18111
+ /** Sidebar narrow lane in a SPLIT_PANEL_OUTER layout. */
18112
+ 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";
18113
+ /**
18114
+ * The tier boundary at which a settings label/value row stops stacking.
18115
+ *
18116
+ * Below it (L1 — compact) the row is ONE column: label on its own line, value
18117
+ * on the next at full width. Above it the row is label-left / value-right as
18118
+ * before. The two-column form needs `SETTING_ROW_LABEL`'s lane plus a readable
18119
+ * value beside it; a phone has neither, and the failure mode is not "cramped"
18120
+ * but "the value is the part that gets cut" — the one thing the operator
18121
+ * opened the page to read.
18122
+ */
18123
+ var SETTING_ROW_STACK_BREAKPOINT = "sm";
18124
+ /** Outer row: single column at L1, label/value columns from `sm` up. */
18125
+ var SETTING_ROW = "flex min-w-0 flex-col gap-0.5 py-1.5 sm:flex-row sm:items-center";
18126
+ /** Label lane. Full width (and free to wrap) at L1; a fixed lane from `sm` up. */
18127
+ var SETTING_ROW_LABEL = "min-w-0 text-[11px] leading-tight text-foreground-subtle sm:w-32 sm:shrink-0 sm:pr-2";
18128
+ /**
18129
+ * Value lane — the value text plus its trailing affordances (copy / reveal).
18130
+ * `w-full` at L1 so the value owns the whole row once the label is above it;
18131
+ * `sm:flex-1` makes it share the row again from `sm` up.
18132
+ */
18133
+ var SETTING_ROW_VALUE = "flex w-full min-w-0 items-center gap-1 sm:flex-1";
18134
+ /**
18135
+ * The value text itself. `break-all` lets an opaque token (a key, a URL) wrap
18136
+ * at L1 instead of being cut mid-word; `sm:truncate` restores the single-line
18137
+ * desktop form, where the row is wide enough for truncation to be a choice
18138
+ * rather than data loss.
18139
+ */
18140
+ var SETTING_ROW_VALUE_TEXT = "min-w-0 break-all text-xs text-foreground sm:truncate";
18141
+ /** Section header label (uppercase tracking-wider). */
18142
+ var TEXT_SECTION_LABEL = "text-[10px] sm:text-[11px] font-semibold text-foreground uppercase tracking-wider";
18143
+ /** Field label inside a row. */
18144
+ var TEXT_FIELD_LABEL = "text-[11px] sm:text-xs text-foreground";
18145
+ /** Sub-label / hint text. */
18146
+ var TEXT_HINT = "text-[10px] text-foreground-subtle";
18147
+ /** Primary value (a metric, badge, …). */
18148
+ var TEXT_VALUE = "text-[11px] sm:text-xs font-medium text-foreground";
18149
+ /** Big metric value (StatCard). */
18150
+ var TEXT_METRIC = "text-base sm:text-lg font-semibold leading-tight text-foreground";
18151
+ /** Default compact button. */
18152
+ 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";
18153
+ /** Primary compact button. */
18154
+ 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";
18155
+ /** Danger compact button. */
18156
+ 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";
18157
+ /** Warning compact button (e.g. cancel-drawing). */
18158
+ 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";
18159
+ /** Compact text/select/number input. */
18160
+ 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";
18161
+ /** Chip-style toggle (zone / class selectors in ZoneRulesEditor). */
18162
+ var CHIP_BASE = "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors";
18163
+ var CHIP_ACTIVE = "border-primary/50 bg-primary/15 text-primary";
18164
+ var CHIP_INACTIVE = "border-border bg-surface text-foreground-subtle hover:bg-surface-hover";
18165
+ //#endregion
18166
+ //#region src/composites/setting-row.tsx
18167
+ function SettingRow({ label, children, actions, elements = "plain", className, valueClassName }) {
18168
+ const LabelTag = elements === "description" ? "dt" : "span";
18169
+ const ValueTag = elements === "description" ? "dd" : "div";
18170
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18171
+ "data-setting-row": "",
18172
+ className: cn(SETTING_ROW, className),
18173
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelTag, {
18174
+ "data-setting-row-label": "",
18175
+ className: SETTING_ROW_LABEL,
18176
+ children: label
18177
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ValueTag, {
18178
+ "data-setting-row-value": "",
18179
+ className: SETTING_ROW_VALUE,
18180
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18181
+ "data-setting-row-value-text": "",
18182
+ className: cn(SETTING_ROW_VALUE_TEXT, valueClassName),
18183
+ children
18184
+ }), actions !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18185
+ className: "flex shrink-0 items-center gap-0.5",
18186
+ children: actions
18187
+ })]
18188
+ })]
18189
+ });
18190
+ }
18191
+ //#endregion
18192
+ //#region src/composites/sort-header-button.tsx
18193
+ function SortHeaderButton({ label, active, dir, onSort }) {
18194
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
18195
+ type: "button",
18196
+ onClick: onSort,
18197
+ className: "group/sort inline-flex items-center gap-0.5 hover:text-foreground transition-colors",
18198
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), active ? dir === "asc" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: cn("h-3 w-3 flex-shrink-0 opacity-0 transition-opacity", "group-hover/sort:opacity-30") })]
18199
+ });
18200
+ }
18201
+ //#endregion
18202
+ //#region src/composites/table-sort.ts
18203
+ /**
18204
+ * The toggle rule, and the only copy of it.
18205
+ *
18206
+ * none → asc → desc → none, and a click on a DIFFERENT column starts that
18207
+ * column fresh at `asc`. `sameColumn` says whether the clicked column is the
18208
+ * one currently sorted; `currentDir` is its direction (`null` when nothing is
18209
+ * sorted). Returns the next direction, or `null` for "unsorted".
18210
+ *
18211
+ * Split out from {@link nextTableSort} so the device list — whose sort state
18212
+ * keys columns by `columnId`, not `columnKey` — can reuse the rule without a
18213
+ * cast and without a second copy of it.
18214
+ */
18215
+ function nextSortDirection(sameColumn, currentDir) {
18216
+ if (!sameColumn || currentDir === null) return "asc";
18217
+ return currentDir === "asc" ? "desc" : null;
18218
+ }
18219
+ /**
18220
+ * Toggle a column's sort on header click: none → asc → desc → none.
18221
+ * Clicking a DIFFERENT column starts it fresh at `asc`.
18222
+ */
18223
+ function nextTableSort(current, columnKey) {
18224
+ const dir = nextSortDirection(current !== null && current.columnKey === columnKey, current === null ? null : current.dir);
18225
+ return dir === null ? null : {
18226
+ columnKey,
18227
+ dir
18228
+ };
18229
+ }
18230
+ /**
18231
+ * Ascending comparison of two cell values.
18232
+ *
18233
+ * Numbers compare numerically and strings compare with `localeCompare` — a
18234
+ * numeric column must never be ordered as text ("100" before "9"), and a text
18235
+ * column must respect the locale's collation. A mixed column (one row a
18236
+ * number, another a string) falls back to string comparison of both, which is
18237
+ * the only total order available; it is not a shape any caller should build on
18238
+ * purpose.
18239
+ *
18240
+ * `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
18241
+ * because its placement is direction-independent and a comparator that is
18242
+ * multiplied by the direction factor cannot express that.
18243
+ */
18244
+ function compareValues(a, b) {
18245
+ if (typeof a === "number" && typeof b === "number") return a - b;
18246
+ return String(a).localeCompare(String(b));
18247
+ }
18248
+ /**
18249
+ * Return a NEW array of `rows` ordered by `sort`.
18250
+ *
18251
+ * `valueOf` is the column's `sortValue` — it is called once per row per
18252
+ * comparison, so it must be cheap (read a field, do not format a string).
18253
+ * `sort === null` ⇒ a fresh shallow copy in the incoming order.
18254
+ */
18255
+ function sortRowsByColumn(rows, sort, valueOf) {
18256
+ if (sort === null) return [...rows];
18257
+ const factor = sort.dir === "asc" ? 1 : -1;
18258
+ return rows.toSorted((a, b) => {
18259
+ const av = valueOf(a);
18260
+ const bv = valueOf(b);
18261
+ if (av === null || bv === null) {
18262
+ if (av === bv) return 0;
18263
+ return av === null ? 1 : -1;
18264
+ }
18265
+ return factor * compareValues(av, bv);
18266
+ });
18267
+ }
18268
+ /** Derive the `<th>` `aria-sort` for `columnKey` from the table's sort state. */
18269
+ function ariaSortForColumn(sort, columnKey) {
18270
+ if (sort === null || sort.columnKey !== columnKey) return "none";
18271
+ return sort.dir === "asc" ? "ascending" : "descending";
18272
+ }
18273
+ //#endregion
18274
+ //#region src/composites/data-table.tsx
18275
+ /**
18276
+ * DataTable — shared responsive table for admin pages.
18277
+ *
18278
+ * Recipes baked in:
18279
+ * - `overflow-x-auto` wrapper so narrow viewports don't clip the
18280
+ * right edge (the rounded border survives the scroll).
18281
+ * - `min-w-[Npx]` on the inner table so columns don't squash below
18282
+ * readable width before the scroll kicks in.
18283
+ * - `whitespace-nowrap` on headers — column titles never wrap to
18284
+ * two lines (`AUTO-KILL` was the regression that triggered this).
18285
+ * - Themed `bg-surface` + `border-border` so contrast works in both
18286
+ * light and dark themes.
18287
+ *
18288
+ * Column model: caller supplies `columns: DataColumn<T>[]` and
18289
+ * `rows: T[]`. Each column declares `header`, optional `align`,
18290
+ * optional `nowrap`, and a `render(row) => ReactNode` that produces
18291
+ * the cell content.
18292
+ *
18293
+ * <DataTable
18294
+ * columns={[
18295
+ * { key: 'username', header: 'Username', render: (u) => u.username },
18296
+ * { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
18297
+ * { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
18298
+ * ]}
18299
+ * rows={users}
18300
+ * minWidthPx={560}
18301
+ * />
18302
+ *
18303
+ * If finer header markup is needed, pass `headerRender` instead of
18304
+ * `header` (e.g. for a sortable click affordance).
18305
+ *
18306
+ * ── Narrow viewports ──────────────────────────────────────────────────────
18307
+ * The table stays a table. The wrapper is `overflow-x-auto`, so a phone
18308
+ * scrolls the columns sideways INSIDE the table — the page body never moves
18309
+ * sideways, and the cells stay one line (`whitespace-nowrap`). That is the
18310
+ * default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
18311
+ * `auto` still applies the old column-count rule for a surface that wants it.
18312
+ * See `resolveTableLayout`.
18313
+ *
18314
+ * ── Scrolling the BODY, not the page ──────────────────────────────────────
18315
+ * `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
18316
+ * with the column header pinned. It is ONE prop, not two, because "this list
18317
+ * scrolls internally" and "its header stays visible" are the same decision: a
18318
+ * capped table whose header scrolls away is a list of unlabelled cells.
18319
+ *
18320
+ * Use it wherever the row count is open-ended (rules, targets, delivery
18321
+ * history) so the page keeps its own header, filters and actions in view while
18322
+ * the operator moves through the rows.
18323
+ */
18324
+ /**
18325
+ * The default cap for an internally-scrolling table.
18326
+ *
18327
+ * `60vh` leaves the page header, its filters and its actions on screen at
18328
+ * every viewport while still showing ~10 rows — and it is a fraction, so it
18329
+ * does not need a breakpoint of its own.
18330
+ */
18331
+ var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
18332
+ var ALIGN_CLASS = {
18333
+ left: "text-left",
18334
+ right: "text-right",
18335
+ center: "text-center"
18336
+ };
18337
+ function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "scroll", maxBodyHeight, defaultSort }) {
18338
+ const isNarrow = useIsMobile();
18339
+ const [sort, setSort] = (0, react$1.useState)(defaultSort ?? null);
18340
+ const layout = resolveTableLayout({
18341
+ mode: mobileMode,
18342
+ columnCount: columns.length,
18343
+ isNarrow
18344
+ });
18345
+ const scrolls = maxBodyHeight !== void 0;
18346
+ const sortedColumn = (0, react$1.useMemo)(() => sort === null ? void 0 : columns.find((col) => col.key === sort.columnKey && col.sortValue !== void 0), [columns, sort]);
18347
+ const sortValue = sortedColumn?.sortValue;
18348
+ const displayRows = (0, react$1.useMemo)(() => sortValue === void 0 ? rows : sortRowsByColumn(rows, sort, sortValue), [
18349
+ rows,
18350
+ sort,
18351
+ sortValue
18352
+ ]);
18353
+ const activeSort = sortedColumn === void 0 ? null : sort;
18354
+ if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18355
+ className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
18356
+ children: emptyMessage
18357
+ });
18358
+ if (rows.length === 0) return null;
18359
+ if (layout === "cards") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18360
+ className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
18361
+ children: displayRows.map((row, rowIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTableCard, {
18362
+ row,
18363
+ rowIndex,
18364
+ columns,
18365
+ bordered,
18366
+ onRowClick,
18367
+ rowClassName
18368
+ }, rowKey ? rowKey(row, rowIndex) : rowIndex))
18369
+ });
18370
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18371
+ className: [
18372
+ "rounded-lg",
18373
+ bordered ? "border border-border" : "",
18374
+ "bg-surface overflow-x-auto",
18375
+ scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
18376
+ className ?? ""
18377
+ ].filter(Boolean).join(" "),
18378
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
18379
+ className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
18380
+ style: { minWidth: `${minWidthPx}px` },
18381
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", {
18382
+ className: "bg-surface",
18383
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: columns.map((col) => {
18384
+ const align = col.align ?? "left";
18385
+ const nowrap = col.nowrap ?? true;
18386
+ const sortable = col.sortValue !== void 0;
18387
+ const isSorted = activeSort !== null && activeSort.columnKey === col.key;
18388
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
18389
+ "aria-sort": sortable ? ariaSortForColumn(activeSort, col.key) : void 0,
18390
+ title: sortable ? void 0 : col.notSortableReason,
18391
+ className: [
18392
+ "px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
18393
+ scrolls ? "sticky top-0 z-10" : "",
18394
+ ALIGN_CLASS[align],
18395
+ nowrap ? "whitespace-nowrap" : "",
18396
+ col.width ?? ""
18397
+ ].filter(Boolean).join(" "),
18398
+ children: sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SortHeaderButton, {
18399
+ label: col.headerRender ? col.headerRender() : col.header,
18400
+ active: isSorted,
18401
+ dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
18402
+ onSort: () => setSort((prev) => nextTableSort(prev, col.key))
18403
+ }) : col.headerRender ? col.headerRender() : col.header
18404
+ }, col.key);
18405
+ }) })
18406
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: displayRows.map((row, rowIndex) => {
18407
+ const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
18408
+ const extra = rowClassName?.(row, rowIndex) ?? "";
18409
+ const interactive = onRowClick !== void 0;
18410
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", {
18411
+ onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
18412
+ className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
18413
+ children: columns.map((col) => {
18414
+ const align = col.align ?? "left";
18415
+ const nowrap = col.nowrap ?? true;
18416
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
18417
+ className: [
18418
+ "px-3 py-2 text-foreground border-b border-border",
18419
+ ALIGN_CLASS[align],
18420
+ nowrap ? "whitespace-nowrap" : ""
18421
+ ].filter(Boolean).join(" "),
18422
+ children: col.render(row, rowIndex)
18423
+ }, col.key);
18424
+ })
18425
+ }, key);
18426
+ }) })]
18427
+ })
18428
+ });
18429
+ }
18430
+ /**
18431
+ * One row as a card. Columns that carry a plain-text `header` become labelled
18432
+ * `SettingRow`s — the label is the column title and the value gets the full
18433
+ * card width. Columns with no text header (an actions column, a
18434
+ * `headerRender`-only column) have no label to show, so they render as a
18435
+ * full-width strip at the foot of the card.
18436
+ */
18437
+ function DataTableCard({ row, rowIndex, columns, bordered, onRowClick, rowClassName }) {
18438
+ const labelled = columns.filter((col) => col.header !== void 0 && col.header !== "");
18439
+ const unlabelled = columns.filter((col) => col.header === void 0 || col.header === "");
18440
+ const interactive = onRowClick !== void 0;
18441
+ const extra = rowClassName?.(row, rowIndex) ?? "";
18442
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18443
+ "data-data-table-card": "",
18444
+ onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
18445
+ className: [
18446
+ "rounded-lg bg-surface px-3 py-1.5 divide-y divide-border-subtle",
18447
+ bordered ? "border border-border" : "",
18448
+ interactive ? "cursor-pointer hover:bg-primary/5" : "",
18449
+ extra
18450
+ ].filter(Boolean).join(" "),
18451
+ children: [labelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
18452
+ label: col.header,
18453
+ children: col.render(row, rowIndex)
18454
+ }, col.key)), unlabelled.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18455
+ className: "flex flex-wrap items-center justify-end gap-2 py-1.5",
18456
+ children: unlabelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: col.render(row, rowIndex) }, col.key))
18457
+ })]
18458
+ });
18459
+ }
18460
+ //#endregion
18461
+ //#region src/composites/device-list/pagination.tsx
18462
+ var NEIGHBORS = 2;
18463
+ function computePageButtons(currentPage, totalPages) {
18464
+ if (totalPages <= 1) return [{
18465
+ kind: "page",
18466
+ n: 1
18467
+ }];
18468
+ if (totalPages <= 7) {
18469
+ const all = [];
18470
+ for (let i = 1; i <= totalPages; i++) all.push({
18471
+ kind: "page",
18472
+ n: i
18473
+ });
18474
+ return all;
18475
+ }
18476
+ const result = [];
18477
+ const leftBound = Math.max(2, currentPage - NEIGHBORS);
18478
+ const rightBound = Math.min(totalPages - 1, currentPage + NEIGHBORS);
18479
+ result.push({
18480
+ kind: "page",
18481
+ n: 1
18482
+ });
18483
+ if (leftBound > 2) result.push({
18484
+ kind: "ellipsis",
18485
+ side: "left"
18486
+ });
18487
+ for (let i = leftBound; i <= rightBound; i++) result.push({
18488
+ kind: "page",
18489
+ n: i
18490
+ });
18491
+ if (rightBound < totalPages - 1) result.push({
18492
+ kind: "ellipsis",
18493
+ side: "right"
18494
+ });
18495
+ result.push({
18496
+ kind: "page",
18497
+ n: totalPages
18498
+ });
18499
+ return result;
18500
+ }
18501
+ function Pagination({ currentPage, totalItems, pageSize, onPageChange, itemNoun = "devices" }) {
18502
+ if (totalItems <= pageSize) return null;
18503
+ const totalPages = Math.ceil(totalItems / pageSize);
18504
+ const startIdx = (currentPage - 1) * pageSize + 1;
18505
+ const endIdx = Math.min(currentPage * pageSize, totalItems);
18506
+ const buttons = computePageButtons(currentPage, totalPages);
18507
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18508
+ className: "flex items-center justify-between gap-4 flex-wrap pt-2 text-[11px] text-foreground-subtle",
18509
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
18510
+ "Showing ",
18511
+ startIdx,
18512
+ "–",
18513
+ endIdx,
18514
+ " of ",
18515
+ totalItems,
18516
+ " ",
18517
+ itemNoun
18518
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
18519
+ className: "inline-flex items-center gap-1",
18520
+ children: [
18521
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18522
+ type: "button",
18523
+ "aria-label": "Previous page",
18524
+ disabled: currentPage === 1,
18525
+ onClick: () => onPageChange(currentPage - 1),
18526
+ 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"),
18527
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { className: "h-3 w-3" })
18528
+ }),
18529
+ buttons.map((b, i) => {
18530
+ if (b.kind === "ellipsis") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18531
+ className: "px-1 text-foreground-subtle/60 select-none",
18532
+ children: "…"
18533
+ }, `e-${i}`);
18534
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18535
+ type: "button",
18536
+ onClick: () => onPageChange(b.n),
18537
+ 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"),
18538
+ children: b.n
18539
+ }, `p-${b.n}`);
18540
+ }),
18541
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18542
+ type: "button",
18543
+ "aria-label": "Next page",
18544
+ disabled: currentPage === totalPages,
18545
+ onClick: () => onPageChange(currentPage + 1),
18546
+ 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"),
18547
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { className: "h-3 w-3" })
18548
+ })
18549
+ ]
18550
+ })]
18551
+ });
18552
+ }
18553
+ //#endregion
18554
+ //#region src/composites/empty-state.tsx
18555
+ function EmptyState({ icon: Icon, title, description, action, className }) {
18556
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18557
+ className: cn("flex flex-col items-center justify-center gap-3 py-12", className),
18558
+ children: [
18559
+ Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
18560
+ className: "h-12 w-12 text-foreground-subtle",
18561
+ "aria-hidden": "true"
18562
+ }),
18563
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18564
+ className: "flex flex-col items-center gap-1 text-center",
18565
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18566
+ className: "text-foreground-muted text-sm font-medium",
18567
+ children: title
18568
+ }), description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18569
+ className: "text-foreground-subtle text-xs max-w-xs",
18570
+ children: description
18571
+ })]
18572
+ }),
18573
+ action && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18574
+ className: "mt-1",
18575
+ children: action
18576
+ })
18577
+ ]
18578
+ });
18579
+ }
18580
+ //#endregion
18581
+ //#region src/composites/error-box.tsx
18582
+ var VARIANT_STYLES$1 = {
18583
+ danger: {
18584
+ box: "border-danger/40 bg-danger/10",
18585
+ icon: "text-danger",
18586
+ title: "text-danger",
18587
+ message: "text-danger"
18588
+ },
18589
+ warning: {
18590
+ box: "border-amber-500/40 bg-amber-500/10",
18591
+ icon: "text-amber-600 dark:text-amber-400",
18592
+ title: "text-amber-700 dark:text-amber-300",
18593
+ message: "text-amber-700 dark:text-amber-300"
18594
+ }
18595
+ };
18596
+ function ErrorBox({ message, title, onRetry, action, variant = "danger", className }) {
18597
+ const styles = VARIANT_STYLES$1[variant];
18598
+ const Icon = variant === "warning" ? TriangleAlert : CircleAlert;
18599
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18600
+ role: "alert",
18601
+ className: cn("flex items-start gap-2.5 rounded border px-3 py-2.5", styles.box, className),
18602
+ children: [
18603
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
18604
+ className: cn("h-4 w-4 mt-0.5 shrink-0", styles.icon),
18605
+ "aria-hidden": "true"
18606
+ }),
18607
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18608
+ className: "flex-1 min-w-0",
18609
+ children: [title && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18610
+ className: cn("text-xs font-semibold", styles.title),
18611
+ children: title
18612
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18613
+ className: cn("text-xs break-words", styles.message, title ? "mt-0.5" : ""),
18614
+ children: message
18615
+ })]
18616
+ }),
18617
+ action ?? (onRetry && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
18618
+ type: "button",
18619
+ onClick: onRetry,
18620
+ 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),
18621
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RefreshCw, {
18622
+ className: "h-3 w-3",
18623
+ "aria-hidden": "true"
18624
+ }), "Retry"]
18625
+ }))
18626
+ ]
18627
+ });
18628
+ }
18629
+ //#endregion
18630
+ //#region src/composites/cap-settings/EventHeatmap.tsx
18631
+ var KIND_BG = {
18632
+ motion: "bg-emerald-500",
18633
+ object: "bg-violet-500",
18634
+ audio: "bg-orange-500"
18635
+ };
18636
+ function EventHeatmap({ windowFrom, windowTo, buckets, selectedIndex, onSelectBucket }) {
18637
+ const peak = buckets.reduce((m, b) => Math.max(m, b.total), 0);
18638
+ const firstBucket = buckets[0];
18639
+ const bucketWidthPct = firstBucket ? pctForMs(firstBucket.endMs, windowFrom, windowTo) - pctForMs(firstBucket.startMs, windowFrom, windowTo) : 0;
18640
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18641
+ className: "relative h-6 rounded bg-background/40",
18642
+ children: buckets.map((b) => {
18643
+ if (b.total === 0 || b.dominant === null) return null;
18644
+ const heightPct = peak > 0 ? Math.max(15, b.total / peak * 100) : 0;
18645
+ const left = pctForMs(b.startMs, windowFrom, windowTo);
18646
+ const selected = b.index === selectedIndex;
18647
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18648
+ type: "button",
18649
+ "data-testid": "heatmap-bar",
18650
+ title: `${new Date(b.startMs).toLocaleTimeString(void 0, {
18651
+ hour: "2-digit",
18652
+ minute: "2-digit"
18653
+ })} · motion ${b.counts.motion} · object ${b.counts.object}`,
18654
+ onClick: () => onSelectBucket(b.index),
18655
+ className: `absolute bottom-0 rounded-t-sm transition-opacity ${KIND_BG[b.dominant]} ${selected ? "opacity-100 ring-1 ring-amber-400" : "opacity-80 hover:opacity-100"}`,
18656
+ style: {
18657
+ left: `${left}%`,
18658
+ width: `${Math.max(.6, bucketWidthPct)}%`,
18659
+ height: `${heightPct}%`
18660
+ }
18661
+ }, b.index);
18662
+ })
18663
+ });
18664
+ }
18665
+ //#endregion
18666
+ //#region src/composites/cap-settings/TimelineControls.tsx
18667
+ /** Format a Date as "YYYY-MM-DD" for <input type="date">. */
18668
+ function formatDateInput(date) {
18669
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
18670
+ }
18671
+ /** Parse "YYYY-MM-DD" from <input type="date"> into a local-midnight Date. */
18672
+ function parseDateInput(value) {
18673
+ const parts = value.split("-");
18674
+ const y = parts[0] !== void 0 ? parseInt(parts[0], 10) : NaN;
18675
+ const m = parts[1] !== void 0 ? parseInt(parts[1], 10) : NaN;
18676
+ const d = parts[2] !== void 0 ? parseInt(parts[2], 10) : NaN;
18677
+ if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
18678
+ return new Date(y, m - 1, d);
18679
+ }
18680
+ /**
18681
+ * Date selector + prev/next pan + zoom-in / zoom-out buttons for the recording
18682
+ * timeline. Zoom cycles through ZOOM_LEVELS (widest → narrowest), anchored on
18683
+ * the view's RIGHT edge — so on today's timeline "now" stays pinned to the right
18684
+ * instead of drifting to a midday range. The ◀ / ▶ buttons step the visible
18685
+ * window earlier/later within the day (the discoverable equivalent of the
18686
+ * horizontal wheel-pan wired in RecordingTimeline), so a zoomed-in day can be
18687
+ * navigated back and forth without a mouse wheel.
18688
+ */
18689
+ function TimelineControls({ day, onDayChange, view, dayBounds, onViewChange }) {
18690
+ const handleDateChange = (e) => {
18691
+ const parsed = parseDateInput(e.target.value);
18692
+ if (parsed !== null) onDayChange(parsed);
18693
+ };
18694
+ const viewSpan = view.toMs - view.fromMs;
18695
+ const currentLevelIdx = (() => {
18696
+ let best = 0;
18697
+ let bestDiff = Infinity;
18698
+ for (let i = 0; i < ZOOM_LEVELS.length; i++) {
18699
+ const level = ZOOM_LEVELS[i];
18700
+ if (level === void 0) continue;
18701
+ const diff = Math.abs(level - viewSpan);
18702
+ if (diff < bestDiff) {
18703
+ bestDiff = diff;
18704
+ best = i;
18705
+ }
18706
+ }
18707
+ return best;
18708
+ })();
18709
+ const rightEdgeMs = view.toMs;
18710
+ const handleZoomIn = () => {
18711
+ const nextLevel = ZOOM_LEVELS[Math.min(currentLevelIdx + 1, ZOOM_LEVELS.length - 1)];
18712
+ if (nextLevel === void 0) return;
18713
+ onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
18714
+ };
18715
+ const handleZoomOut = () => {
18716
+ const nextLevel = ZOOM_LEVELS[Math.max(currentLevelIdx - 1, 0)];
18717
+ if (nextLevel === void 0) return;
18718
+ onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
18719
+ };
18720
+ const canZoomIn = currentLevelIdx < ZOOM_LEVELS.length - 1;
18721
+ const canZoomOut = currentLevelIdx > 0;
18722
+ const panStepMs = Math.max(1, Math.round(viewSpan * .9));
18723
+ const canPanPrev = view.fromMs > dayBounds.fromMs;
18724
+ const canPanNext = view.toMs < dayBounds.toMs;
18725
+ const handlePanPrev = () => {
18726
+ onViewChange(panWindow(view, dayBounds, -panStepMs));
18727
+ };
18728
+ const handlePanNext = () => {
18729
+ onViewChange(panWindow(view, dayBounds, panStepMs));
18730
+ };
18731
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18732
+ className: "flex items-center gap-2",
18733
+ children: [
18734
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18735
+ type: "date",
18736
+ value: formatDateInput(day),
18737
+ max: formatDateInput(/* @__PURE__ */ new Date()),
18738
+ onChange: handleDateChange,
18739
+ className: "rounded border border-border bg-background px-2 py-0.5 text-[11px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary",
18740
+ "aria-label": "Select recording day"
18741
+ }),
18742
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18743
+ className: "ml-auto flex items-center gap-1",
18744
+ children: [
18745
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18746
+ type: "button",
18747
+ onClick: handlePanPrev,
18748
+ disabled: !canPanPrev,
18749
+ title: "Earlier (pan back)",
18750
+ "aria-label": "Pan earlier",
18751
+ className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
18752
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { size: 14 })
18753
+ }),
18754
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18755
+ type: "button",
18756
+ onClick: handlePanNext,
18757
+ disabled: !canPanNext,
18758
+ title: "Later (pan forward)",
18759
+ "aria-label": "Pan later",
18760
+ className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
18761
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { size: 14 })
18762
+ }),
18763
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18764
+ className: "mx-0.5 h-3 w-px bg-border",
18765
+ "aria-hidden": "true"
18766
+ }),
18767
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18768
+ type: "button",
18769
+ onClick: handleZoomOut,
18770
+ disabled: !canZoomOut,
18771
+ title: "Zoom out",
18772
+ "aria-label": "Zoom out",
18773
+ className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
18774
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { size: 13 })
18775
+ }),
18776
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18777
+ type: "button",
18778
+ onClick: handleZoomIn,
18779
+ disabled: !canZoomIn,
18780
+ title: "Zoom in",
18781
+ "aria-label": "Zoom in",
18782
+ className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
18783
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { size: 13 })
18784
+ })
18785
+ ]
18786
+ }),
18787
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18788
+ className: "text-[9px] text-foreground-subtle",
18789
+ children: zoomLabel(viewSpan)
18790
+ })
18791
+ ]
18792
+ });
18793
+ }
18794
+ function zoomLabel(spanMs) {
18795
+ const h = spanMs / 36e5;
18796
+ if (h >= 24) return "24h";
18797
+ if (h >= 1) return `${Math.round(h)}h`;
18798
+ const m = spanMs / 6e4;
18799
+ return `${Math.round(m)}m`;
18800
+ }
18801
+ //#endregion
18802
+ //#region src/composites/clips/ClipBandBar.tsx
18803
+ /**
18804
+ * The clips section's timebar — the recording section's own controls, driving
18805
+ * a clip list instead of a player (D564).
18806
+ *
18807
+ * *"Possiamo utilizzare lo stesso componente della sezione recording con la
18808
+ * timebar dove selezionare un periodo e mostrare le clip solo di quella banda
18809
+ * selezionata, espandibile."* — the operator, 2026-09-20. So this is not a
18810
+ * second timebar: it is `TimelineControls` and `EventHeatmap`, the two pieces
18811
+ * `RecordingTimeline` is itself built from, over the same
18812
+ * `recording-timeline-model` (`dayWindow`, `panWindow`,
18813
+ * `zoomWindowAnchoredRight`, `bucketMsForSpan`, `axisTicks`). One
18814
+ * implementation, one model, two surfaces.
18815
+ *
18816
+ * ## What it does NOT take from the recording timeline
18817
+ *
18818
+ * `CoverageTrack` stays behind. There it is the seek surface — a click drives
18819
+ * the hero player through the `camstack-control` channel or an HLS VOD
18820
+ * request. A copy here would either be a dead bar or a SECOND thing claiming
18821
+ * to move the playhead, and two components holding two ideas of the same
18822
+ * position is the defect this repo keeps paying for. The clips section plays a
18823
+ * clip through `ClipPanel`, which is its own, already-shipped path.
18824
+ *
18825
+ * ## This component is presentational
18826
+ *
18827
+ * It holds no state. The day, the visible window and the picked bucket all
18828
+ * live in `ClipsSection`, because that is where the band is turned into the
18829
+ * `listClips` argument — a bar that kept its own copy of the selection would
18830
+ * be the second authority again, one component down.
18831
+ */
18832
+ /**
18833
+ * How many ms one pixel of horizontal wheel delta pans.
18834
+ *
18835
+ * The same calibration `RecordingTimeline` uses — a standard wheel tick
18836
+ * (~100 px of `deltaX`) pans ~30 min. Two timebars that panned at different
18837
+ * rates would read as two different controls.
18838
+ */
18839
+ var PAN_MS_PER_PX$1 = 18e3 / 100;
18840
+ function ClipBandBar({ day, onDayChange, view, dayBounds, onViewChange, buckets, selectedBucketIndex, onSelectBucket, densityLoading }) {
18841
+ const trackAreaRef = (0, react$1.useRef)(null);
18842
+ const handleWheel = (0, react$1.useCallback)((e) => {
18843
+ const deltaX = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.shiftKey ? e.deltaY : 0;
18844
+ if (deltaX === 0) return;
18845
+ e.preventDefault();
18846
+ onViewChange(panWindow(view, dayBounds, deltaX * PAN_MS_PER_PX$1));
18847
+ }, [
18848
+ view,
18849
+ dayBounds,
18850
+ onViewChange
18851
+ ]);
18852
+ const ticks = axisTicks(view.fromMs, view.toMs, 5);
18853
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18854
+ className: "space-y-1",
18855
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimelineControls, {
18856
+ day,
18857
+ onDayChange,
18858
+ view,
18859
+ dayBounds,
18860
+ onViewChange
18861
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18862
+ ref: trackAreaRef,
18863
+ onWheel: handleWheel,
18864
+ className: "space-y-1",
18865
+ children: [
18866
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18867
+ className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
18868
+ children: "Events · density"
18869
+ }),
18870
+ densityLoading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "h-6 animate-pulse rounded bg-background/40" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EventHeatmap, {
18871
+ windowFrom: view.fromMs,
18872
+ windowTo: view.toMs,
18873
+ buckets,
18874
+ selectedIndex: selectedBucketIndex,
18875
+ onSelectBucket
18876
+ }),
18877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18878
+ className: "relative h-3",
18879
+ children: ticks.map((tick) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18880
+ className: "absolute -translate-x-1/2 text-[9px] text-foreground-subtle",
18881
+ style: { left: `${String(tick.pct)}%` },
18882
+ children: tick.label
18883
+ }, tick.pct))
18884
+ })
18885
+ ]
18886
+ })]
18887
+ });
18888
+ }
18889
+ //#endregion
18890
+ //#region src/contexts/vod-playback.tsx
18891
+ /**
18892
+ * VodPlayback — shared context coordinating recorded (VOD) playback between a
18893
+ * widget/section (the Recording panel) and the hero StreamPanel.
18894
+ *
18895
+ * A CONTROLLER (the Recording widget) calls `play({ url, label })`; the hero
18896
+ * StreamPanel CONSUMES `request` and switches into VOD mode (HLS playback
18897
+ * through the hub data-plane) instead of the live WebRTC stream. `clear()`
18898
+ * returns the hero to live.
18899
+ *
18900
+ * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
18901
+ * the Module-Federation boundary: an addon-shipped widget consuming
18902
+ * `useVodPlayback()` resolves the SAME provider instance the host mounted —
18903
+ * the same mechanism `player-overlays` / `zone-editing` use to let federated
18904
+ * widgets (zone-editor, privacy-mask) drive the live player. Consumers used
18905
+ * outside a provider get the inert default (no-op), so a StreamPanel mounted
18906
+ * on a page without the Recording widget stays live-only without crashing.
18907
+ */
18908
+ var VodPlaybackContext = createSharedContext("camstack:vod-playback", {
18909
+ request: null,
18910
+ play: () => {},
18911
+ clear: () => {}
18912
+ });
18913
+ function VodPlaybackProvider({ children }) {
18914
+ const [request, setRequest] = (0, react$1.useState)(null);
18915
+ const value = (0, react$1.useMemo)(() => ({
18916
+ request,
18917
+ play: (req) => setRequest(req),
18918
+ clear: () => setRequest(null)
18919
+ }), [request]);
18920
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VodPlaybackContext.Provider, {
18921
+ value,
18922
+ children
18923
+ });
18924
+ }
18925
+ /** @returns the VOD playback controller (inert no-op outside a provider). */
18926
+ function useVodPlayback() {
18927
+ return (0, react$1.useContext)(VodPlaybackContext);
18928
+ }
18929
+ //#endregion
18930
+ //#region src/primitives/card.tsx
18931
+ var cardVariants = cva("rounded-lg p-3", {
18932
+ variants: { variant: {
18933
+ flat: "bg-surface",
18934
+ bordered: "bg-surface border border-border"
18935
+ } },
18936
+ defaultVariants: { variant: "bordered" }
18937
+ });
18938
+ var Card = (0, react$1.forwardRef)(({ className, variant, ...props }, ref) => {
18939
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18940
+ ref,
18941
+ className: cn(cardVariants({ variant }), className),
18942
+ ...props
18943
+ });
18944
+ });
18945
+ Card.displayName = "Card";
18946
+ //#endregion
18947
+ //#region src/composites/clips/ClipPanel.tsx
18948
+ /**
18949
+ * The selected clip — what it is, how it plays, and how it is saved.
18950
+ *
18951
+ * **No second player.** `getClipPlayback` answers `{ playbackUrl, format,
18952
+ * served }` and the panel hands it to the SAME hero the recordings timeline
18953
+ * drives: `vod.play({ url, format })` → `StreamPanel playbackUrl/playbackFormat`
18954
+ * → `HlsVideo`'s `mp4` branch (`video.src`, no hls.js). Building a clip player
18955
+ * here is the thing D549 decision 22 exists to refuse.
18956
+ *
18957
+ * **`format` travels.** An MP4 handed to hls.js is parsed as a manifest and
18958
+ * fails, so the format the provider named is forwarded verbatim and never
18959
+ * defaulted here.
18960
+ *
18961
+ * **`served` is shown.** A clip that holds only one twin answers with the one
18962
+ * it has, and the surface SAYS so — a missing main twin must never be served
18963
+ * silently as if it were the asked-for quality (D549 decision 15). Both ids
18964
+ * are already on the row, so changing quality re-asks the hub and never
18965
+ * re-searches the camera.
18966
+ *
18967
+ * **Nothing is appended to the URL.** The route is `access:'authenticated'`
18968
+ * and the session cookie authenticates it (D549 decision 22); the same URL is
18969
+ * the download.
18970
+ */
18971
+ var PROFILES = [
18972
+ "low",
18973
+ "mid",
18974
+ "high"
18975
+ ];
18976
+ function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
18977
+ const vod = useVodPlayback();
18978
+ const playback = useVideoclipsGetClipPlayback({
18979
+ deviceId,
18980
+ clipId: clip.id,
18981
+ profile
18982
+ });
18983
+ const data = playback.data;
18984
+ const label = `${new Date(clip.timeRange.startMs).toLocaleString()} · ${clip.source}`;
18985
+ (0, react$1.useEffect)(() => {
18986
+ if (data === void 0) return;
18987
+ vod.play({
18988
+ url: hubPath(data.playbackUrl),
18989
+ ...data.format === void 0 ? {} : { format: data.format },
18990
+ label
18991
+ });
18992
+ }, [data, label]);
18993
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
18994
+ className: "space-y-2 text-sm",
18995
+ children: [
18996
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18997
+ className: "font-medium text-foreground",
18998
+ children: label
18999
+ }),
19000
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19001
+ className: "flex items-center gap-1",
19002
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19003
+ className: "mr-1 text-[10px] uppercase tracking-wide text-foreground-subtle",
19004
+ children: "Quality"
19005
+ }), PROFILES.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19006
+ variant: option === profile ? "primary" : "secondary",
19007
+ "aria-pressed": option === profile,
19008
+ onClick: () => onProfileChange(option),
19009
+ children: option
19010
+ }, option))]
19011
+ }),
19012
+ playback.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19013
+ className: "space-y-1",
19014
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19015
+ role: "status",
19016
+ className: "text-xs text-foreground-subtle",
19017
+ children: "Preparing the clip — the file is fetched and muxed once…"
19018
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-4 w-48" })]
19019
+ }) : playback.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19020
+ title: "This clip could not be prepared",
19021
+ message: playback.error?.message ?? "no reason given"
19022
+ }) : data === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19023
+ className: "space-y-2",
19024
+ children: [data.served === void 0 || data.served === profile ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19025
+ variant: "warning",
19026
+ message: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
19027
+ "Served the ",
19028
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: data.served }),
19029
+ " stream — this clip has no ",
19030
+ profile,
19031
+ " ",
19032
+ "twin."
19033
+ ] })
19034
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
19035
+ href: hubPath(data.playbackUrl),
19036
+ download: true,
19037
+ className: "inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:underline",
19038
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Download, {
19039
+ className: "h-3.5 w-3.5",
19040
+ "aria-hidden": "true"
19041
+ }), "Download this clip"]
19042
+ })]
19043
+ })
19044
+ ]
19045
+ });
19046
+ }
19047
+ //#endregion
19048
+ //#region src/lib/format-duration.ts
19049
+ /**
19050
+ * How long something lasted, the way this product says it.
19051
+ *
19052
+ * Promoted verbatim from `composites/cap-settings/RecordingPanel.tsx`, which
19053
+ * had been the only place answering this question about MEDIA. It is now the
19054
+ * only place, so a clip's length and a recording's retention read the same.
19055
+ *
19056
+ * Two other private formatters stay where they are, on purpose:
19057
+ * `config-form-field.tsx` formats a config VALUE (sub-second `ms`, `—` for a
19058
+ * value that is not a number), and `scene-monitor-copy.ts`'s `humanDuration`
19059
+ * is deliberately single-unit and coarse for a sentence. Neither is a media
19060
+ * length, and folding them in here would change what they say.
19061
+ */
19062
+ function formatDurationMs(ms) {
19063
+ const totalSec = Math.round(ms / 1e3);
19064
+ const h = Math.floor(totalSec / 3600);
19065
+ const m = Math.floor(totalSec % 3600 / 60);
19066
+ const s = totalSec % 60;
19067
+ if (h > 0) return `${h}h ${m}m`;
19068
+ if (m > 0) return `${m}m ${s}s`;
19069
+ return `${s}s`;
19070
+ }
19071
+ //#endregion
19072
+ //#region src/composites/clips/clip-tile-media.ts
19073
+ /**
19074
+ * Why no still exists, in words, keyed by the refusal CODE.
19075
+ *
19076
+ * A code this table does not know falls back to the provider's own prose, so a
19077
+ * firmware inventing a refusal reaches the operator rather than becoming a
19078
+ * blank tile.
19079
+ */
19080
+ var REFUSAL_TEXT = {
19081
+ sleeping: () => "The camera is asleep — a still would cost a wake",
19082
+ "camera-refused": (reason) => `The camera refused — ${reason}`,
19083
+ "no-keyframe": () => "No decodable frame inside this window",
19084
+ unsupported: () => "This source cannot produce stills",
19085
+ "queue-full": () => "Waiting for the camera — its still queue is full",
19086
+ "camera-backoff": () => "The camera is resting after refusing — trying again shortly",
19087
+ "no-catalog-row": () => "This clip is no longer in the catalog",
19088
+ "unknown-device": () => "This camera is not served here",
19089
+ "uid-missing": () => "The camera’s id is not known yet"
19090
+ };
19091
+ function wordsFor(code, reason) {
19092
+ const known = code === void 0 ? void 0 : REFUSAL_TEXT[code];
19093
+ return known === void 0 ? reason : known(reason);
19094
+ }
19095
+ function refusal(reason, code) {
19096
+ return {
19097
+ kind: "unavailable",
19098
+ reason,
19099
+ text: wordsFor(code, reason)
19100
+ };
19101
+ }
19102
+ /** What to draw for one clip's tile. */
19103
+ function resolveTileMedia(clip) {
19104
+ if (clip.thumbnail !== void 0) return {
19105
+ kind: "vouched",
19106
+ url: clip.thumbnail
19107
+ };
19108
+ if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason, clip.thumbnailUnavailable.reason);
19109
+ if (clip.thumbnailMint !== void 0) return {
19110
+ kind: "mint",
19111
+ url: clip.thumbnailMint
19112
+ };
19113
+ return {
19114
+ kind: "none",
19115
+ text: "This clip carries no still and offered none"
19116
+ };
19117
+ }
19118
+ /**
19119
+ * A 204, as something the tile can draw.
19120
+ *
19121
+ * `deferred` is the ONLY answer that comes back, and only because the server
19122
+ * said so. Everything else — an unknown disposition, an absent one, a code
19123
+ * this build has never heard of — is final, so the failure mode of a
19124
+ * vocabulary that moves is a tile that stops early, never one that asks for
19125
+ * ever.
19126
+ */
19127
+ function describeRefusal(headers) {
19128
+ if (headers.disposition === "deferred") return {
19129
+ kind: "deferred",
19130
+ reason: headers.reason,
19131
+ text: wordsFor(headers.code, headers.reason),
19132
+ retryAfterMs: headers.retryAfterMs ?? null
19133
+ };
19134
+ return refusal(headers.reason, headers.code);
19135
+ }
19136
+ //#endregion
19137
+ //#region src/composites/clips/clip-thumbnail-fetch.ts
19138
+ /**
19139
+ * Asking a `thumbnailMint` route for a still — and hearing what it says.
19140
+ *
19141
+ * The route answers **200** with a JPEG or **204** with a refusal. An
19142
+ * `<img src>` cannot read a 204's headers: it fires `onerror` with nothing to
19143
+ * say, turning a named refusal into an anonymous broken tile. So a mint goes
19144
+ * through `fetch`.
19145
+ *
19146
+ * **Nothing is appended to the URL.** Both data-plane routes are registered
19147
+ * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
19148
+ * the session cookie the admin UI sets at login, so the URL is never a
19149
+ * credential (D549 decision 22) — it may sit in history or a `Referer` and
19150
+ * open nothing.
19151
+ *
19152
+ * ## Three headers, one of which decides everything (D563)
19153
+ *
19154
+ * `x-camstack-reason-code` is the token the tile keys its words on,
19155
+ * `x-camstack-reason` is the camera's own prose, and
19156
+ * `x-camstack-disposition` says whether to come back. This module reads them;
19157
+ * it does not classify them. A response with no disposition — every
19158
+ * provider-reolink build before D563 — is FINAL, which is precisely the
19159
+ * behaviour that shipped.
19160
+ *
19161
+ * A thrown fetch is DEFERRED: nothing refused anything, one request lost a
19162
+ * race, and a tile that goes permanently blank for that is the same defect
19163
+ * with a different cause.
19164
+ */
19165
+ /**
19166
+ * Ask a mint route for one still.
19167
+ *
19168
+ * Never rejects: a thrown fetch is a refusal like any other, because a tile
19169
+ * that throws is a tile that renders nothing and says nothing — the exact
19170
+ * failure this module exists to prevent.
19171
+ */
19172
+ async function fetchMintedThumbnail(url, fetcher) {
19173
+ let response;
19174
+ try {
19175
+ response = await fetcher(url, { credentials: "same-origin" });
19176
+ } catch (err) {
19177
+ return deferredNetworkFailure(err);
19178
+ }
19179
+ if (response.status === 204) return describeRefusal(refusalHeadersOf(response));
19180
+ if (!response.ok) return describeRefusal({
19181
+ code: void 0,
19182
+ reason: `HTTP ${String(response.status)}`,
19183
+ disposition: void 0
19184
+ });
19185
+ try {
19186
+ return {
19187
+ kind: "image",
19188
+ blob: await response.blob()
19189
+ };
19190
+ } catch (err) {
19191
+ return deferredNetworkFailure(err);
19192
+ }
19193
+ }
19194
+ function refusalHeadersOf(response) {
19195
+ const retryAfter = response.headers.get("retry-after");
19196
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
19197
+ return {
19198
+ code: response.headers.get(_camstack_types.CLIP_STILL_REASON_CODE_HEADER) ?? void 0,
19199
+ reason: response.headers.get(_camstack_types.CLIP_STILL_REASON_HEADER) ?? "no reason given",
19200
+ disposition: response.headers.get(_camstack_types.CLIP_STILL_DISPOSITION_HEADER) ?? void 0,
19201
+ retryAfterMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null
19202
+ };
19203
+ }
19204
+ function deferredNetworkFailure(err) {
19205
+ return describeRefusal({
19206
+ code: void 0,
19207
+ reason: err instanceof Error ? err.message : String(err),
19208
+ disposition: "deferred"
19209
+ });
19210
+ }
19211
+ /** First wait, doubling from there. */
19212
+ var STILL_RETRY_BASE_MS = 4e3;
19213
+ /** No single wait is longer than this, whatever the server hints. */
19214
+ var STILL_RETRY_MAX_WAIT_MS = 3e4;
19215
+ /** ±25 %: enough to break a synchronised herd, not enough to stop being a
19216
+ * schedule. */
19217
+ var JITTER_FRACTION = .25;
19218
+ /** The next wait, or `null` when the budget is spent. */
19219
+ function planStillRetry(input) {
19220
+ if (input.attempt >= 4 || input.attempt < 0) return null;
19221
+ const curve = STILL_RETRY_BASE_MS * 2 ** input.attempt;
19222
+ const chosen = Math.max(curve, input.serverHintMs ?? 0);
19223
+ const bounded = Math.min(chosen, STILL_RETRY_MAX_WAIT_MS);
19224
+ const jitter = 1 + (input.random() * 2 - 1) * JITTER_FRACTION;
19225
+ return { waitMs: Math.round(bounded * jitter) };
19226
+ }
19227
+ //#endregion
19228
+ //#region src/composites/clips/ClipTile.tsx
19229
+ /**
19230
+ * One clip, as a tile.
19231
+ *
19232
+ * The picture is resolved by `resolveTileMedia` — vouched JPEG, an offer, or
19233
+ * the reason there is none (D549 decision 5). An offer is fetched rather than
19234
+ * dropped into an `<img src>`, because the route's **204 + x-camstack-reason**
19235
+ * is invisible to an `<img>`: it fires `onerror` with nothing to say. The
19236
+ * refusal is LATCHED for the life of the tile and never retried behind the
19237
+ * operator's back.
19238
+ *
19239
+ * The row also carries what the camera itself said: its native types, the
19240
+ * behaviours beside the class, which storages hold it, and — for a row that
19241
+ * is an EVENT sitting inside no file — `playable: false` with its reason
19242
+ * (measured: 1 of 29 hub events on 3628). Such a row is SHOWN, never dropped
19243
+ * and never offered as playable-then-failing (D549 decision 19).
19244
+ *
19245
+ * The CHROME is the library's: a `Card` for the tile, `Badge` for every label
19246
+ * the camera gave it, `Skeleton` while a mint is in flight. Only the ladder
19247
+ * above is this file's own, because only it is a decision.
19248
+ */
19249
+ function clockOf(ms) {
19250
+ return new Date(ms).toLocaleTimeString(void 0, {
19251
+ hour: "2-digit",
17930
19252
  minute: "2-digit",
17931
19253
  second: "2-digit"
17932
19254
  });
17933
19255
  }
17934
19256
  /**
17935
- * Ask a mint route once, and LATCH whatever it says.
19257
+ * Ask a mint route, and come back only for the answers that say to.
17936
19258
  *
17937
19259
  * The offer is memoised on the three fields it is derived from, not rebuilt
17938
19260
  * per render: `resolveTileMedia` returns a fresh object every call, and an
@@ -17940,8 +19262,12 @@ function clockOf(ms) {
17940
19262
  * effect sets state, is an infinite render loop. It was one here, and it hung
17941
19263
  * the suite rather than failing it.
17942
19264
  *
17943
- * The refusal is latched for the life of the tile: a camera that just said
17944
- * "busy" is not asked again behind the operator's back (D549 decision 5).
19265
+ * A FINAL refusal is latched for the life of the tile: a camera that just said
19266
+ * "asleep" is not asked again behind the operator's back. A DEFERRED one is
19267
+ * not a refusal at all — the still is queued — so the tile keeps showing
19268
+ * loading and re-asks on `planStillRetry`'s bounded, jittered schedule. When
19269
+ * that budget runs out the deferred answer becomes what the tile SAYS, so the
19270
+ * end state is words, never a blank box and never another request (D563).
17945
19271
  */
17946
19272
  function useMintedStill(clip) {
17947
19273
  const offered = (0, react$1.useMemo)(() => resolveTileMedia(clip), [
@@ -17956,7 +19282,8 @@ function useMintedStill(clip) {
17956
19282
  if (mintUrl === null || typeof fetch !== "function") return void 0;
17957
19283
  let objectUrl = null;
17958
19284
  let cancelled = false;
17959
- (async () => {
19285
+ let timer;
19286
+ const ask = async (attempt) => {
17960
19287
  const answer = await fetchMintedThumbnail(hubPath(mintUrl), fetch);
17961
19288
  if (cancelled) return;
17962
19289
  if (answer.kind === "image") {
@@ -17967,10 +19294,31 @@ function useMintedStill(clip) {
17967
19294
  });
17968
19295
  return;
17969
19296
  }
17970
- setLatched(answer);
17971
- })();
19297
+ if (answer.kind !== "deferred") {
19298
+ setLatched(answer);
19299
+ return;
19300
+ }
19301
+ const plan = planStillRetry({
19302
+ attempt,
19303
+ serverHintMs: answer.retryAfterMs,
19304
+ random: Math.random
19305
+ });
19306
+ if (plan === null) {
19307
+ setLatched({
19308
+ kind: "unavailable",
19309
+ reason: answer.reason,
19310
+ text: answer.text
19311
+ });
19312
+ return;
19313
+ }
19314
+ timer = setTimeout(() => {
19315
+ ask(attempt + 1);
19316
+ }, plan.waitMs);
19317
+ };
19318
+ ask(0);
17972
19319
  return () => {
17973
19320
  cancelled = true;
19321
+ if (timer !== void 0) clearTimeout(timer);
17974
19322
  if (objectUrl !== null) URL.revokeObjectURL(objectUrl);
17975
19323
  };
17976
19324
  }, [mintUrl]);
@@ -17980,51 +19328,90 @@ function ClipTile({ clip, selected, onSelect }) {
17980
19328
  const media = useMintedStill(clip);
17981
19329
  const playable = clip.playable !== false;
17982
19330
  const labels = [...clip.labels ?? [], ...clip.behaviours ?? []];
17983
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
19331
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17984
19332
  type: "button",
17985
19333
  onClick: () => onSelect(clip),
17986
19334
  disabled: !playable,
17987
19335
  "aria-current": selected ? "true" : void 0,
17988
- className: `flex w-full gap-3 rounded-md border p-2 text-left ${selected ? "border-primary" : "border-border"} ${playable ? "" : "opacity-70"}`,
17989
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17990
- className: "flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded bg-muted text-[10px] text-muted-foreground",
17991
- children: media.kind === "vouched" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
17992
- src: media.url,
17993
- alt: "",
17994
- className: "h-full w-full object-cover"
17995
- }) : media.kind === "mint" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "minting…" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17996
- className: "px-1 text-center",
17997
- children: media.text
17998
- })
17999
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18000
- className: "min-w-0 flex-1",
18001
- children: [
18002
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18003
- className: "font-medium",
18004
- children: clockOf(clip.timeRange.startMs)
18005
- }),
18006
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18007
- className: "text-xs text-muted-foreground",
18008
- children: clip.source
18009
- }),
18010
- labels.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18011
- className: "text-xs",
18012
- children: labels.join(" · ")
18013
- }),
18014
- clip.nativeTypes === void 0 || clip.nativeTypes.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18015
- className: "text-[10px] text-muted-foreground",
18016
- children: clip.nativeTypes.join(", ")
18017
- }),
18018
- playable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18019
- className: "text-xs text-amber-600 dark:text-amber-400",
18020
- children: ["Not playable — ", clip.unplayableReason ?? "the source gave no reason"]
19336
+ className: "block w-full text-left disabled:cursor-not-allowed",
19337
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
19338
+ className: `flex gap-3 transition-colors ${selected ? "border-primary" : ""} ${playable ? "hover:bg-surface-hover" : "opacity-70"}`,
19339
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19340
+ className: "flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded bg-surface-hover text-[10px] text-foreground-subtle",
19341
+ children: media.kind === "vouched" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
19342
+ src: media.url,
19343
+ alt: "",
19344
+ className: "h-full w-full object-cover"
19345
+ }) : media.kind === "mint" || media.kind === "deferred" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19346
+ role: "status",
19347
+ "aria-label": media.kind === "mint" ? "Loading the still" : media.text,
19348
+ className: "h-full w-full",
19349
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-full w-full rounded-none" })
19350
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19351
+ className: "px-1 text-center leading-tight",
19352
+ children: media.text
18021
19353
  })
18022
- ]
18023
- })]
19354
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19355
+ className: "min-w-0 flex-1 space-y-1",
19356
+ children: [
19357
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19358
+ className: "flex items-baseline gap-2",
19359
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19360
+ className: "text-sm font-medium text-foreground",
19361
+ children: clockOf(clip.timeRange.startMs)
19362
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19363
+ className: "text-[11px] text-foreground-subtle",
19364
+ children: clip.inProgress === true ? "Recording…" : formatDurationMs(clip.timeRange.endMs - clip.timeRange.startMs)
19365
+ })]
19366
+ }),
19367
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19368
+ className: "text-[10px] text-foreground-subtle",
19369
+ children: clip.source
19370
+ }),
19371
+ labels.length === 0 && clip.nativeTypes === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19372
+ className: "flex flex-wrap gap-1",
19373
+ children: [labels.map((label) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
19374
+ variant: "info",
19375
+ children: label
19376
+ }, `label:${label}`)), (clip.nativeTypes ?? []).map((native) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
19377
+ style: "outline",
19378
+ children: native
19379
+ }, `native:${native}`))]
19380
+ }),
19381
+ playable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19382
+ className: "text-[11px] text-warning",
19383
+ children: ["Not playable — ", clip.unplayableReason ?? "the source gave no reason"]
19384
+ })
19385
+ ]
19386
+ })]
19387
+ })
18024
19388
  }) });
18025
19389
  }
18026
19390
  //#endregion
18027
19391
  //#region src/composites/clips/clip-day-window.ts
19392
+ /**
19393
+ * LOCAL calendar-day arithmetic for the clips browser.
19394
+ *
19395
+ * The browser no longer NAVIGATES days — it follows a band selected on the
19396
+ * recording timebar (D564) — but the local day survived the move as the unit
19397
+ * the COST is counted in: a Reolink search is clamped to one camera-local day
19398
+ * per call, so `clip-band.ts` counts a band's local days to decide whether it
19399
+ * may be asked for. The window builder this file used to own went with the day
19400
+ * picker; `recording-timeline-model.ts`'s `dayWindow(date, nowMs)` is the one
19401
+ * that builds windows now, and one directory holding two functions of that
19402
+ * name was the confusion worth deleting.
19403
+ *
19404
+ * Two properties are pinned by spec because both are invisible until they are
19405
+ * wrong:
19406
+ *
19407
+ * - Deriving the day in UTC shifts every band by the browser's offset, and an
19408
+ * operator in Berlin would see a 22:00 clip filed under the next day.
19409
+ * - Walking days by adding 86 400 000 ms repeats or skips a calendar day
19410
+ * across a DST change. The walk goes through `Date`'s own day arithmetic.
19411
+ *
19412
+ * A day id is `YYYY-MM-DD` — it orders lexicographically, which is what lets
19413
+ * the band's day walk terminate on a comparison rather than on a count.
19414
+ */
18028
19415
  function pad2(value) {
18029
19416
  return value < 10 ? `0${String(value)}` : String(value);
18030
19417
  }
@@ -18037,16 +19424,6 @@ function startOfDay(dayId) {
18037
19424
  function toDayId(instant) {
18038
19425
  return `${String(instant.getFullYear())}-${pad2(instant.getMonth() + 1)}-${pad2(instant.getDate())}`;
18039
19426
  }
18040
- /** The half-open local-wall-clock window of one day. */
18041
- function dayWindow$1(dayId) {
18042
- const start = startOfDay(dayId);
18043
- const end = startOfDay(dayId);
18044
- end.setDate(end.getDate() + 1);
18045
- return {
18046
- since: start.getTime(),
18047
- until: end.getTime()
18048
- };
18049
- }
18050
19427
  /** Walk `days` calendar days from `dayId` — DST-safe, because `Date` does it. */
18051
19428
  function shiftDay(dayId, days) {
18052
19429
  const moved = startOfDay(dayId);
@@ -18054,11 +19431,12 @@ function shiftDay(dayId, days) {
18054
19431
  return toDayId(moved);
18055
19432
  }
18056
19433
  /**
18057
- * The day, spelled out for the header.
19434
+ * The day, spelled out — the calendar half of a band's label.
18058
19435
  *
18059
- * An empty list has to say WHICH day it is empty for — "no clips" with no date
18060
- * beside it is the ambiguity this surface exists to remove — so this label is
18061
- * rendered next to every state, not only the populated one.
19436
+ * An empty list has to say WHICH period it is empty for; "no clips" with no
19437
+ * date beside it is the ambiguity this surface exists to remove. `formatBandLabel`
19438
+ * puts the times around it, and every state renders the result, not only the
19439
+ * populated one.
18062
19440
  */
18063
19441
  function formatDayLabel(dayId) {
18064
19442
  return startOfDay(dayId).toLocaleDateString(void 0, {
@@ -18068,6 +19446,77 @@ function formatDayLabel(dayId) {
18068
19446
  day: "numeric"
18069
19447
  });
18070
19448
  }
19449
+ /** Defensive ceiling on the day walk so an inverted band can never loop. */
19450
+ var MAX_WALK_DAYS = 366;
19451
+ /**
19452
+ * How many LOCAL calendar days the half-open band touches.
19453
+ *
19454
+ * The end instant is exclusive, so a band that ends exactly at the next local
19455
+ * midnight is one day, not two — otherwise every whole-day band would report
19456
+ * double what it costs.
19457
+ */
19458
+ function localDaysInBand(band) {
19459
+ const lastInstant = Math.max(band.fromMs, band.toMs - 1);
19460
+ const endDay = toDayId(new Date(lastInstant));
19461
+ let cursor = toDayId(new Date(band.fromMs));
19462
+ let days = 0;
19463
+ while (cursor <= endDay && days < MAX_WALK_DAYS) {
19464
+ days += 1;
19465
+ cursor = shiftDay(cursor, 1);
19466
+ }
19467
+ return days;
19468
+ }
19469
+ /** Whether this band may be asked for, and what it costs, in one value. */
19470
+ function planClipBand(band) {
19471
+ const localDays = localDaysInBand(band);
19472
+ if (localDays > 2) return {
19473
+ kind: "too-wide",
19474
+ band,
19475
+ localDays,
19476
+ maxLocalDays: 2
19477
+ };
19478
+ return {
19479
+ kind: "askable",
19480
+ band,
19481
+ localDays
19482
+ };
19483
+ }
19484
+ /**
19485
+ * The band for the current selection: the picked bucket, or — when nothing is
19486
+ * picked — the whole visible window.
19487
+ *
19488
+ * "Nothing picked means everything visible" is safe here and only here,
19489
+ * because the visible window is itself bounded by the timebar. It is never
19490
+ * "everything the camera holds" (D382's unstated ask).
19491
+ */
19492
+ function bandForSelection(view, bucket) {
19493
+ if (bucket === null) return {
19494
+ fromMs: view.fromMs,
19495
+ toMs: view.toMs
19496
+ };
19497
+ return {
19498
+ fromMs: Math.max(bucket.startMs, view.fromMs),
19499
+ toMs: Math.min(bucket.endMs, view.toMs)
19500
+ };
19501
+ }
19502
+ function formatTime$2(instant) {
19503
+ return new Date(instant).toLocaleTimeString(void 0, {
19504
+ hour: "2-digit",
19505
+ minute: "2-digit"
19506
+ });
19507
+ }
19508
+ /**
19509
+ * The band, spelled out.
19510
+ *
19511
+ * Every state renders it — an empty list has to say WHICH period it is empty
19512
+ * for, the same rule the day label carried before the band replaced it.
19513
+ */
19514
+ function formatBandLabel(band) {
19515
+ const fromDay = toDayId(new Date(band.fromMs));
19516
+ const toDay = toDayId(new Date(Math.max(band.fromMs, band.toMs - 1)));
19517
+ if (fromDay === toDay) return `${formatDayLabel(fromDay)}, ${formatTime$2(band.fromMs)} – ${formatTime$2(band.toMs)}`;
19518
+ return `${formatDayLabel(fromDay)} ${formatTime$2(band.fromMs)} – ${formatDayLabel(toDay)} ${formatTime$2(band.toMs)}`;
19519
+ }
18071
19520
  //#endregion
18072
19521
  //#region src/composites/clips/clip-source-state.ts
18073
19522
  var STATE_COPY = {
@@ -18129,13 +19578,13 @@ function formatCatalogAge(catalogAsOf, now) {
18129
19578
  }
18130
19579
  //#endregion
18131
19580
  //#region src/composites/clips/clip-view-state.ts
18132
- /** Keep only the rows of selected sources; an absent selection keeps them all. */
18133
- function applySelection(clips, selected) {
18134
- if (selected === void 0) return clips;
18135
- const allowed = new Set(selected);
18136
- return clips.filter((clip) => allowed.has(clip.source));
18137
- }
18138
19581
  function resolveClipsView(input) {
19582
+ if (input.plan.kind === "too-wide") return {
19583
+ kind: "refused",
19584
+ band: input.plan.band,
19585
+ localDays: input.plan.localDays,
19586
+ maxLocalDays: input.plan.maxLocalDays
19587
+ };
18139
19588
  if (input.clipsStatus === "error") return {
18140
19589
  kind: "failed",
18141
19590
  message: input.clipsError ?? "The clip list could not be read"
@@ -18148,10 +19597,10 @@ function resolveClipsView(input) {
18148
19597
  const notices = input.sources.map(describeSource);
18149
19598
  const answered = notices.filter((notice) => notice.answered);
18150
19599
  const silent = notices.filter((notice) => !notice.answered);
18151
- const clips = applySelection(input.clips, input.selected);
19600
+ const clips = input.clips;
18152
19601
  if (clips.length === 0) return {
18153
19602
  kind: "empty",
18154
- dayId: input.dayId,
19603
+ band: input.plan.band,
18155
19604
  answered,
18156
19605
  silent,
18157
19606
  nobodyAnswered: answered.length === 0
@@ -18164,174 +19613,415 @@ function resolveClipsView(input) {
18164
19613
  };
18165
19614
  }
18166
19615
  //#endregion
18167
- //#region src/composites/clips/ClipsBrowser.tsx
19616
+ //#region src/composites/clips/ClipsSection.tsx
18168
19617
  /**
18169
- * `host/clips-browser` — a camera's clips, from EVERY source it has.
19618
+ * `host/clips-browser` — a camera's clips, from EVERY source it has, as the
19619
+ * LAST SECTION of the Recording tab.
18170
19620
  *
18171
19621
  * Mounted by the framework, not by hand: `videoclips.cap.ts` declares
18172
19622
  * `deviceConfig.ui = { kind:'widget', widgetId:'host/clips-browser',
18173
- * tab:'clips', topTab:true }` and `deriveDeviceConfigContribution` emits the
18174
- * section (D14). `DeviceDetail.tsx` is not edited — placement is data, and
18175
- * `device-config-widget-wrapped-binding.spec.ts` pins that the derive works
18176
- * for this cap's WRAPPED binding entry, which no shipped widget cap had
18177
- * exercised before.
18178
- *
18179
- * ## What it shows, and why every part of it exists
18180
- *
18181
- * A camera's clips live in several places at once — the analytics visit log,
18182
- * the camera's own SD card, a hub's storage — and the operator asked for all
18183
- * of them side by side, never one hidden behind another (D549 decision 2). So:
19623
+ * tab:'recording', topTab:true, order:100 }` and
19624
+ * `deriveDeviceConfigContribution` emits the section (D14). `DeviceDetail.tsx`
19625
+ * is not edited — placement is data. Because the admin Recordings page renders
19626
+ * every `location:'top-tab'` + `tab:'recording'` section behind its camera
19627
+ * picker (`CameraRecordingSettingsSection`), the same declaration puts this on
19628
+ * both surfaces with no second wiring.
19629
+ *
19630
+ * ## Built from the library, not from scratch
19631
+ *
19632
+ * It shipped as its own top tab made of five bespoke components, and the
19633
+ * operator rejected both: *"La UI è orribile, non segue la nostra UI affatto …
19634
+ * Io in realtà utilizzerei la stessa tab recordings, lì abbiamo già tutto il
19635
+ * necessario, una nuova sezione alla fine per le clips dove possiamo
19636
+ * selezionare le source con un multiselect common, non componenti nuovi
19637
+ * anonimi."* So every piece of chrome here is a component this repo already
19638
+ * has: `Select`, `DataTable`, `EmptyState`, `ErrorBox`, `Pagination`, `Card`,
19639
+ * `Badge`, `Button`, `Skeleton`. What the chrome CARRIES is unchanged, and it
19640
+ * is the feature:
18184
19641
  *
18185
19642
  * - **The source list is complete, always.** Every source the camera has,
18186
- * with its availability and the age of its catalog. Unticking one hides its
18187
- * ROWS; the source itself never leaves the list.
19643
+ * with its availability and the age of its catalog, whichever provider is
19644
+ * being asked. A source never leaves the table.
18188
19645
  * - **Nothing is deduped.** A clip held in two storages appears twice, once
18189
- * per source, and every row says which. That is stated as a consequence in
18190
- * D549 so it is not "fixed" later.
19646
+ * per source, and every tile says which (D549).
18191
19647
  * - **Four states are tellable apart**: loading, empty-with-a-cause,
18192
19648
  * degraded-per-source, and each silent source in its own words. Every one
18193
- * of them used to render as "no clips", which is the defect this whole
18194
- * line of work is about — a camera with no SD card (640) is not quiet, and
18195
- * a camera whose own index disagrees with its own calendar (618, 6–20
19649
+ * of them used to render as "no clips", which is the defect this whole line
19650
+ * of work is about — a camera with no SD card (640) is not quiet, and a
19651
+ * camera whose own index disagrees with its own calendar (618, 6–20
18196
19652
  * September) is not quiet either.
18197
19653
  *
18198
- * ## What the picker WRITES — nothing
18199
- *
18200
- * The tick boxes are the cap's own `sources` view filter, forwarded to
18201
- * `listClips`, and they persist nothing. The persistent authority for turning
18202
- * a source off for a camera is `deviceManager.setWrapperActive`, whose deny
18203
- * set is keyed by **addon id** (D556 decision 3) — and one addon serves TWO
18204
- * sources of a hub child (`native:reolink:onboard` and `native:reolink:hub`,
18205
- * both `provider-reolink`). Wiring a per-SOURCE tick box to a per-ADDON
18206
- * authority would make "hide the hub's event view" silently mean "turn off
18207
- * both Reolink sources": a switch that does something other than what it says,
18208
- * which is worse than no switch (D62). The gap and its fix are recorded in
18209
- * ADR 557; until a source row names its own provider, this surface offers the
18210
- * filter it can honour and no second store.
19654
+ * ## The period comes from the recording section's timebar, not a day picker
19655
+ *
19656
+ * It shipped with its own `‹ day ›` pair and the operator replaced it the same
19657
+ * way he replaced the chrome: *"Possiamo utilizzare lo stesso componente della
19658
+ * sezione recording con la timebar dove selezionare un periodo e mostrare le
19659
+ * clip solo di quella banda selezionata, espandibile."* So the list follows a
19660
+ * BAND (D564) — `TimelineControls` for the day, the zoom and the pan,
19661
+ * `EventHeatmap` for picking a narrower window out of it, both of them the
19662
+ * pieces `RecordingTimeline` is itself built from, over the same
19663
+ * `recording-timeline-model`. Three things that are decisions, not details:
19664
+ *
19665
+ * - **The band lives HERE, once.** `ClipBandBar` is presentational. Two
19666
+ * components holding two ideas of the selected period is the defect this
19667
+ * repo keeps paying for, and the period is what the query is made of.
19668
+ * - **`CoverageTrack` stayed behind**, because there it is the seek surface
19669
+ * that moves the hero player's playhead. A copy here would be a dead bar or
19670
+ * a second claimant on that position.
19671
+ * - **A band is asked for only if it is affordable.** `clip-band.ts` counts
19672
+ * the LOCAL days it touches — the camera's own search is clamped to one per
19673
+ * call, and a day listing is 4.3–6.0 s on 592 — and refuses past the bound
19674
+ * by name rather than asking and rendering whichever days answered.
19675
+ *
19676
+ * ## The picker WRITES nothing, and there is nothing for it to write
19677
+ *
19678
+ * A clip source is not enabled or disabled — it EXISTS, because of a
19679
+ * configuration that lives somewhere else. The operator put it exactly:
19680
+ * *"non è affatto necessario lo stato di abilitato o meno delle sources. Loro
19681
+ * vivono lì, ad esempio hksv c'è se la camera è esportata su homekit, reolink
19682
+ * la dà di suo così come il camstack."* HKSV is there if the camera is
19683
+ * exported to HomeKit; the Reolink source is there because Reolink is that
19684
+ * camera's provider; CamStack's is always there. None of that is a decision to
19685
+ * make in a clips list, so this surface offers no switch and writes no
19686
+ * authority (D557, amended). A directory-wide guard in
19687
+ * `clips-section-sources.test.tsx` keeps it that way.
19688
+ *
19689
+ * ## Why the picker is a SELECT and not a multiselect
19690
+ *
19691
+ * The operator asked for *"un multiselect common"* while `listClips` still
19692
+ * took `sources?: string[]`. It now takes ONE `provider` — *"il get videoclips
19693
+ * prendere un provider opzionale, di default va su camstack … l'utilizzatore è
19694
+ * uno solo"* — and a multi-pick control over a one-at-a-time argument offers a
19695
+ * sum the hub cannot answer. So the control is the shared single-choice one,
19696
+ * `Select`: the same component the Recordings page's own camera picker uses,
19697
+ * which is what *"segue la nostra UI"* means here. Absent means the camera's
19698
+ * BOUND provider, and the option that carries it is labelled with that
19699
+ * provider's own sources rather than the word "default".
19700
+ *
19701
+ * ## One provider is a fleet STATE
19702
+ *
19703
+ * `listSources` derives from `deviceManager.getBindings`, and today nothing
19704
+ * binds `provider-reolink` for `videoclips`, so a real camera answers with one
19705
+ * provider. The selector is then inert and SAYS why. An empty or silently
19706
+ * single-entry control would read as a defect; the binding store learning to
19707
+ * hold several providers for a collection cap is what changes this, and
19708
+ * nothing here needs to change with it.
18211
19709
  */
18212
19710
  /** Newest-first, and bounded: a busy day on 592 lists 546 clips. */
18213
19711
  var CLIP_PAGE_LIMIT = 500;
18214
- function ClipsBrowser({ deviceId }) {
18215
- const [dayId, setDayId] = (0, react$1.useState)(() => toDayId(/* @__PURE__ */ new Date()));
18216
- const [hidden, setHidden] = (0, react$1.useState)([]);
19712
+ /** Tiles per page. The rest are a `Pagination` click away, never dropped. */
19713
+ var TILES_PER_PAGE = 60;
19714
+ var TONE_BADGE = {
19715
+ ok: "success",
19716
+ warn: "warning",
19717
+ error: "danger"
19718
+ };
19719
+ var SOURCE_COLUMNS = [
19720
+ {
19721
+ key: "source",
19722
+ header: "Source",
19723
+ render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19724
+ className: "min-w-0",
19725
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19726
+ className: "font-medium text-foreground",
19727
+ children: row.notice.label
19728
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19729
+ className: "text-[10px] text-foreground-subtle",
19730
+ children: row.notice.source
19731
+ })]
19732
+ })
19733
+ },
19734
+ {
19735
+ key: "state",
19736
+ header: "State",
19737
+ nowrap: false,
19738
+ render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
19739
+ variant: TONE_BADGE[row.notice.tone],
19740
+ children: row.notice.headline
19741
+ })
19742
+ },
19743
+ {
19744
+ key: "reason",
19745
+ header: "Reason",
19746
+ nowrap: false,
19747
+ render: (row) => row.notice.detail === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19748
+ className: "text-foreground-subtle",
19749
+ children: "—"
19750
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19751
+ className: "break-words",
19752
+ children: row.notice.detail
19753
+ })
19754
+ },
19755
+ {
19756
+ key: "read",
19757
+ header: "Catalog read",
19758
+ render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19759
+ className: "text-foreground-subtle",
19760
+ children: row.catalogAge ?? "live"
19761
+ })
19762
+ }
19763
+ ];
19764
+ /** The sources that contributed NOTHING to what is on screen, and why. */
19765
+ function SilentSources({ silent }) {
19766
+ if (silent.length === 0) return null;
19767
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19768
+ variant: "warning",
19769
+ title: silent.length === 1 ? "One source contributed nothing to this list" : `${String(silent.length)} sources contributed nothing to this list`,
19770
+ message: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19771
+ className: "space-y-1",
19772
+ children: silent.map((notice) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
19773
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19774
+ className: "font-medium",
19775
+ children: notice.label
19776
+ }),
19777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
19778
+ className: "opacity-70",
19779
+ children: [
19780
+ " (",
19781
+ notice.source,
19782
+ ")"
19783
+ ]
19784
+ }),
19785
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: notice.headline }),
19786
+ notice.detail === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19787
+ className: "opacity-80 break-words",
19788
+ children: notice.detail
19789
+ })
19790
+ ] }, notice.source))
19791
+ })
19792
+ });
19793
+ }
19794
+ function ClipsSection({ deviceId }) {
19795
+ const [day, setDay] = (0, react$1.useState)(() => /* @__PURE__ */ new Date());
19796
+ const [view, setView] = (0, react$1.useState)(() => dayWindow(/* @__PURE__ */ new Date(), Date.now()));
19797
+ const [selectedBucket, setSelectedBucket] = (0, react$1.useState)(null);
19798
+ const [provider, setProvider] = (0, react$1.useState)("");
19799
+ const [page, setPage] = (0, react$1.useState)(1);
18217
19800
  const [selectedClip, setSelectedClip] = (0, react$1.useState)(null);
18218
19801
  const [profile, setProfile] = (0, react$1.useState)("low");
18219
19802
  const presence = useCapabilityPresence("videoclips");
18220
- const window = (0, react$1.useMemo)(() => dayWindow$1(dayId), [dayId]);
19803
+ const dayBounds = (0, react$1.useMemo)(() => dayWindow(day, Date.now()), [day]);
19804
+ const bucketMs = bucketMsForSpan(view.toMs - view.fromMs);
19805
+ const density = usePipelineAnalyticsGetEventDensity({
19806
+ deviceId,
19807
+ since: view.fromMs,
19808
+ until: view.toMs,
19809
+ bucketMs
19810
+ });
19811
+ const buckets = (0, react$1.useMemo)(() => densityRowsToBuckets(density.data ?? [], view.fromMs, bucketMs), [
19812
+ density.data,
19813
+ view.fromMs,
19814
+ bucketMs
19815
+ ]);
19816
+ const selected = selectedBucket === null ? void 0 : buckets.find((b) => b.index === selectedBucket);
19817
+ const band = (0, react$1.useMemo)(() => bandForSelection(view, selected === void 0 ? null : {
19818
+ startMs: selected.startMs,
19819
+ endMs: selected.endMs
19820
+ }), [view, selected]);
19821
+ const plan = planClipBand(band);
18221
19822
  const sources = useVideoclipsListSources({ deviceId });
18222
19823
  const clips = useVideoclipsListClips({
18223
19824
  deviceId,
18224
- since: window.since,
18225
- until: window.until,
18226
- limit: CLIP_PAGE_LIMIT
18227
- });
19825
+ since: band.fromMs,
19826
+ until: band.toMs,
19827
+ limit: CLIP_PAGE_LIMIT,
19828
+ ...provider === "" ? {} : { provider }
19829
+ }, { enabled: plan.kind === "askable" });
18228
19830
  const sourceRows = sources.data ?? [];
18229
- const selected = sourceRows.map((row) => row.source).filter((source) => !hidden.includes(source));
18230
- const view = resolveClipsView({
18231
- dayId,
19831
+ const clipsView = resolveClipsView({
19832
+ plan,
18232
19833
  clipsStatus: clips.status,
18233
19834
  clips: clips.data ?? [],
18234
19835
  sourcesStatus: sources.status,
18235
19836
  sources: sourceRows,
18236
- ...hidden.length === 0 ? {} : { selected },
18237
19837
  ...clips.error ? { clipsError: clips.error.message } : {},
18238
19838
  ...sources.error ? { sourcesError: sources.error.message } : {}
18239
19839
  });
18240
19840
  const now = Date.now();
18241
- const toggle = (source, next) => setHidden((current) => next ? current.filter((id) => id !== source) : current.includes(source) ? current : [...current, source]);
18242
- if (presence === "pending") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18243
- role: "status",
18244
- children: "Asking the registry whether this cluster serves clips…"
19841
+ const options = (0, react$1.useMemo)(() => {
19842
+ const byAddon = /* @__PURE__ */ new Map();
19843
+ for (const row of sourceRows) {
19844
+ const addonId = row.addonId;
19845
+ if (addonId === void 0) continue;
19846
+ const labels = byAddon.get(addonId);
19847
+ if (labels === void 0) byAddon.set(addonId, [row.label]);
19848
+ else labels.push(row.label);
19849
+ }
19850
+ const rest = [...byAddon].map(([value, labels]) => ({
19851
+ value,
19852
+ label: labels.join(" · ")
19853
+ }));
19854
+ const first = rest[0];
19855
+ return [{
19856
+ value: "",
19857
+ label: first === void 0 ? "The camera’s bound provider" : first.label
19858
+ }, ...rest.slice(1)];
19859
+ }, [sourceRows]);
19860
+ const tableRows = (0, react$1.useMemo)(() => sourceRows.map((row) => ({
19861
+ notice: describeSource(row),
19862
+ catalogAge: formatCatalogAge(row.availability.catalogAsOf, now)
19863
+ })), [sourceRows]);
19864
+ (0, react$1.useEffect)(() => {
19865
+ setPage(1);
19866
+ }, [band.fromMs, band.toMs]);
19867
+ const handleDayChange = (next) => {
19868
+ setDay(next);
19869
+ setView(dayWindow(next, Date.now()));
19870
+ setSelectedBucket(null);
19871
+ };
19872
+ const handleViewChange = (next) => {
19873
+ setView(next);
19874
+ setSelectedBucket(null);
19875
+ };
19876
+ const handleSelectBucket = (index) => {
19877
+ setSelectedBucket((current) => current === index ? null : index);
19878
+ };
19879
+ const pick = (next) => {
19880
+ setProvider(next);
19881
+ setPage(1);
19882
+ };
19883
+ if (presence === "pending") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19884
+ className: "space-y-2",
19885
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19886
+ role: "status",
19887
+ className: "text-xs text-foreground-subtle",
19888
+ children: "Asking the registry whether this cluster serves clips…"
19889
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-24 w-full" })]
18245
19890
  });
18246
- if (presence === "unknown") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18247
- role: "alert",
18248
- children: "The capability registry could not be reached, so whether this camera has clip sources is unknown — not absent."
19891
+ if (presence === "unknown") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19892
+ variant: "warning",
19893
+ title: "Clip sources unknown",
19894
+ message: "The capability registry could not be reached, so whether this camera has clip sources is unknown — not absent."
18249
19895
  });
18250
- if (presence === "absent") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19896
+ if (presence === "absent") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18251
19897
  role: "status",
18252
- children: [
18253
- "No provider of ",
18254
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children: "videoclips" }),
18255
- " is installed in this cluster."
18256
- ]
19898
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
19899
+ icon: Clapperboard,
19900
+ title: "No provider of videoclips in this cluster",
19901
+ description: "Nothing is installed that could list a camera's stored clips."
19902
+ })
18257
19903
  });
19904
+ const visible = clipsView.kind === "listed" ? clipsView.clips : [];
19905
+ const pageClips = visible.slice((page - 1) * TILES_PER_PAGE, page * TILES_PER_PAGE);
18258
19906
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18259
- className: "space-y-4",
19907
+ className: "space-y-3",
18260
19908
  children: [
18261
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
18262
- "aria-label": "Clip sources",
18263
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
18264
- className: "text-sm font-semibold",
18265
- children: "Sources"
18266
- }), sources.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18267
- role: "status",
18268
- children: "Loading this camera's clip sources…"
18269
- }) : sourceRows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18270
- role: "status",
18271
- children: "No provider answered for this camera — nothing claims to hold clips for it."
18272
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", { children: sourceRows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipSourceRow, {
18273
- notice: describeSource(row),
18274
- catalogAge: formatCatalogAge(row.availability.catalogAsOf, now),
18275
- checked: !hidden.includes(row.source),
18276
- onToggle: toggle
18277
- }, row.source)) })]
18278
- }),
18279
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
18280
- "aria-label": "Day",
18281
- className: "flex items-center gap-2",
19909
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
19910
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
19911
+ className: "text-sm font-semibold text-foreground",
19912
+ children: "Clips"
19913
+ }),
19914
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19915
+ className: "mt-1 text-[11px] text-foreground-subtle",
19916
+ 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."
19917
+ }),
19918
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19919
+ className: "mt-1 text-[11px] text-foreground-subtle",
19920
+ children: "The list follows the period selected on the timebar below — the recording section's own control. Zoom out to widen it, pan or pick a day to move it, click a density bar to narrow it to that window."
19921
+ })
19922
+ ] }),
19923
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19924
+ className: "flex flex-wrap items-center gap-2",
18282
19925
  children: [
18283
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18284
- type: "button",
18285
- "aria-label": "Previous day",
18286
- onClick: () => setDayId(shiftDay(dayId, -1)),
18287
- children: "‹"
18288
- }),
18289
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18290
- className: "font-medium",
18291
- children: formatDayLabel(dayId)
19926
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
19927
+ htmlFor: "clips-provider",
19928
+ className: "text-[10px] uppercase tracking-wide text-foreground-subtle",
19929
+ children: "Source"
18292
19930
  }),
18293
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18294
- type: "button",
18295
- "aria-label": "Next day",
18296
- onClick: () => setDayId(shiftDay(dayId, 1)),
18297
- children: "›"
19931
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19932
+ className: "w-60",
19933
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Select, {
19934
+ id: "clips-provider",
19935
+ options,
19936
+ value: provider,
19937
+ disabled: options.length < 2,
19938
+ onChange: (e) => pick(e.target.value)
19939
+ })
18298
19940
  }),
18299
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18300
- type: "button",
18301
- onClick: () => setDayId(toDayId(/* @__PURE__ */ new Date())),
18302
- children: "Today"
19941
+ options.length < 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19942
+ className: "text-[11px] text-foreground-subtle",
19943
+ children: "This camera is bound to one clip provider today."
19944
+ }) : null,
19945
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19946
+ className: "ml-auto flex items-center gap-1",
19947
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19948
+ className: "px-1 text-xs font-medium text-foreground",
19949
+ children: formatBandLabel(band)
19950
+ }), selectedBucket === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19951
+ variant: "outline",
19952
+ onClick: () => setSelectedBucket(null),
19953
+ children: "Expand to the visible window"
19954
+ })]
18303
19955
  })
18304
19956
  ]
18305
19957
  }),
19958
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipBandBar, {
19959
+ day,
19960
+ onDayChange: handleDayChange,
19961
+ view,
19962
+ dayBounds,
19963
+ onViewChange: handleViewChange,
19964
+ buckets,
19965
+ selectedBucketIndex: selectedBucket,
19966
+ onSelectBucket: handleSelectBucket,
19967
+ densityLoading: density.status === "pending"
19968
+ }),
19969
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
19970
+ "aria-label": "Clip sources",
19971
+ className: "space-y-1",
19972
+ children: sources.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-16 w-full" }) : tableRows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
19973
+ icon: Film,
19974
+ title: "No provider answered for this camera",
19975
+ description: "Nothing claims to hold clips for it."
19976
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTable, {
19977
+ columns: SOURCE_COLUMNS,
19978
+ rows: [...tableRows],
19979
+ minWidthPx: 520
19980
+ })
19981
+ }),
18306
19982
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
18307
19983
  "aria-label": "Clips",
18308
- children: view.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19984
+ className: "space-y-2",
19985
+ children: clipsView.kind === "refused" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19986
+ variant: "warning",
19987
+ title: "That period is too wide to list",
19988
+ message: `${formatBandLabel(clipsView.band)} covers ${String(clipsView.localDays)} local days, and a clip listing is asked one local day at a time — at most ${String(clipsView.maxLocalDays)}. Nothing was asked for; narrow the band and the list follows it.`
19989
+ }) : clipsView.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18309
19990
  role: "status",
19991
+ className: "text-xs text-foreground-subtle",
18310
19992
  children: [
18311
19993
  "Loading clips for ",
18312
- formatDayLabel(dayId),
19994
+ formatBandLabel(band),
18313
19995
  "…"
18314
19996
  ]
18315
- }) : view.kind === "failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18316
- role: "alert",
18317
- children: ["The clip list could not be read: ", view.message]
18318
- }) : view.kind === "empty" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18319
- className: "space-y-2",
18320
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18321
- role: "status",
18322
- children: view.nobodyAnswered ? `No source could answer for ${formatDayLabel(dayId)}.` : `No clips on ${formatDayLabel(dayId)} from ${view.answered.map((notice) => notice.label).join(", ")}.`
18323
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipSilentSources, { silent: view.silent })]
18324
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18325
- className: "space-y-2",
18326
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipSilentSources, { silent: view.silent }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
18327
- className: "space-y-2",
18328
- children: view.clips.map((clip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipTile, {
19997
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-24 w-full" })] }) : clipsView.kind === "failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19998
+ title: "The clip list could not be read",
19999
+ message: clipsView.message
20000
+ }) : clipsView.kind === "empty" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
20001
+ role: "status",
20002
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
20003
+ icon: Film,
20004
+ title: clipsView.nobodyAnswered ? `No source could answer for ${formatBandLabel(clipsView.band)}` : `No clips in ${formatBandLabel(clipsView.band)}`,
20005
+ ...clipsView.nobodyAnswered ? {} : { description: `Asked ${clipsView.answered.map((notice) => notice.label).join(", ")}.` }
20006
+ })
20007
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: clipsView.silent })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
20008
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: clipsView.silent }),
20009
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
20010
+ className: "grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3",
20011
+ children: pageClips.map((clip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipTile, {
18329
20012
  clip,
18330
20013
  selected: selectedClip?.id === clip.id,
18331
20014
  onSelect: setSelectedClip
18332
20015
  }, `${clip.source}:${clip.id}`))
18333
- })]
18334
- })
20016
+ }),
20017
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pagination, {
20018
+ currentPage: page,
20019
+ totalItems: visible.length,
20020
+ pageSize: TILES_PER_PAGE,
20021
+ onPageChange: setPage,
20022
+ itemNoun: "clips"
20023
+ })
20024
+ ] })
18335
20025
  }),
18336
20026
  selectedClip === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipPanel, {
18337
20027
  deviceId,
@@ -20462,82 +22152,6 @@ function useScrubController(sink, isPlayerLive = () => false) {
20462
22152
  };
20463
22153
  }
20464
22154
  //#endregion
20465
- //#region src/composites/empty-state.tsx
20466
- function EmptyState({ icon: Icon, title, description, action, className }) {
20467
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
20468
- className: cn("flex flex-col items-center justify-center gap-3 py-12", className),
20469
- children: [
20470
- Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
20471
- className: "h-12 w-12 text-foreground-subtle",
20472
- "aria-hidden": "true"
20473
- }),
20474
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
20475
- className: "flex flex-col items-center gap-1 text-center",
20476
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
20477
- className: "text-foreground-muted text-sm font-medium",
20478
- children: title
20479
- }), description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
20480
- className: "text-foreground-subtle text-xs max-w-xs",
20481
- children: description
20482
- })]
20483
- }),
20484
- action && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
20485
- className: "mt-1",
20486
- children: action
20487
- })
20488
- ]
20489
- });
20490
- }
20491
- //#endregion
20492
- //#region src/composites/error-box.tsx
20493
- var VARIANT_STYLES$1 = {
20494
- danger: {
20495
- box: "border-danger/40 bg-danger/10",
20496
- icon: "text-danger",
20497
- title: "text-danger",
20498
- message: "text-danger"
20499
- },
20500
- warning: {
20501
- box: "border-amber-500/40 bg-amber-500/10",
20502
- icon: "text-amber-600 dark:text-amber-400",
20503
- title: "text-amber-700 dark:text-amber-300",
20504
- message: "text-amber-700 dark:text-amber-300"
20505
- }
20506
- };
20507
- function ErrorBox({ message, title, onRetry, action, variant = "danger", className }) {
20508
- const styles = VARIANT_STYLES$1[variant];
20509
- const Icon = variant === "warning" ? TriangleAlert : CircleAlert;
20510
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
20511
- role: "alert",
20512
- className: cn("flex items-start gap-2.5 rounded border px-3 py-2.5", styles.box, className),
20513
- children: [
20514
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
20515
- className: cn("h-4 w-4 mt-0.5 shrink-0", styles.icon),
20516
- "aria-hidden": "true"
20517
- }),
20518
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
20519
- className: "flex-1 min-w-0",
20520
- children: [title && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
20521
- className: cn("text-xs font-semibold", styles.title),
20522
- children: title
20523
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
20524
- className: cn("text-xs break-words", styles.message, title ? "mt-0.5" : ""),
20525
- children: message
20526
- })]
20527
- }),
20528
- action ?? (onRetry && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
20529
- type: "button",
20530
- onClick: onRetry,
20531
- 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),
20532
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RefreshCw, {
20533
- className: "h-3 w-3",
20534
- "aria-hidden": "true"
20535
- }), "Retry"]
20536
- }))
20537
- ]
20538
- });
20539
- }
20540
- //#endregion
20541
22155
  //#region src/composites/cap-settings/recording-spans.ts
20542
22156
  /**
20543
22157
  * Merge availability ranges (across every profile) into the union of distinct
@@ -20587,201 +22201,6 @@ function formatBytes(bytes) {
20587
22201
  return `${value >= 100 ? Math.round(value).toString() : value.toFixed(1)} ${unit}`;
20588
22202
  }
20589
22203
  //#endregion
20590
- //#region src/composites/cap-settings/recording-timeline-model.ts
20591
- /** Tie-break precedence for a bucket's dominant kind (first wins on equal count). */
20592
- var KIND_PRECEDENCE = [
20593
- "object",
20594
- "motion",
20595
- "audio"
20596
- ];
20597
- /**
20598
- * Convert server-side density rows into `DensityBucket[]` for the timeline
20599
- * heatmap. Rows with total === 0 are skipped (defensive; server already omits
20600
- * them). Returns a sparse, ascending-by-index array — EventHeatmap positions
20601
- * each bar by `b.startMs` rather than array index, so gaps are fine.
20602
- */
20603
- function densityRowsToBuckets(rows, windowFrom, bucketMs) {
20604
- const buckets = [];
20605
- for (const row of rows) {
20606
- const counts = {
20607
- motion: row.motion,
20608
- object: row.object,
20609
- audio: row.audio
20610
- };
20611
- const total = counts.motion + counts.object + counts.audio;
20612
- if (total === 0) continue;
20613
- const index = Math.floor((row.bucketStart - windowFrom) / bucketMs);
20614
- let dominant = null;
20615
- let best = -1;
20616
- for (const kind of KIND_PRECEDENCE) if (counts[kind] > best) {
20617
- best = counts[kind];
20618
- dominant = kind;
20619
- }
20620
- buckets.push({
20621
- index,
20622
- startMs: row.bucketStart,
20623
- endMs: row.bucketStart + bucketMs,
20624
- counts,
20625
- total,
20626
- dominant
20627
- });
20628
- }
20629
- return buckets.toSorted((a, b) => a.index - b.index);
20630
- }
20631
- /** Distance from `ms` to the interval `[startMs, endMs]` (0 if inside). */
20632
- function distanceToSpan(ms, span) {
20633
- if (ms < span.startMs) return span.startMs - ms;
20634
- if (ms > span.endMs) return ms - span.endMs;
20635
- return 0;
20636
- }
20637
- /**
20638
- * Resolve a playback window for a click at `clickMs` against recorded `spans`.
20639
- * Inside a span → play from the click to that span's end. In a gap / outside →
20640
- * snap to the WHOLE nearest span. No spans → null (nothing to play).
20641
- * `spans` are expected ascending by start (as `mergeAvailabilitySpans` returns).
20642
- */
20643
- function seekTargetForSpans(clickMs, spans) {
20644
- if (spans.length === 0) return null;
20645
- const containing = spans.find((s) => clickMs >= s.startMs && clickMs <= s.endMs);
20646
- if (containing) return {
20647
- fromMs: clickMs,
20648
- toMs: containing.endMs
20649
- };
20650
- let nearest = null;
20651
- let bestDist = Infinity;
20652
- for (const s of spans) {
20653
- const d = distanceToSpan(clickMs, s);
20654
- if (d < bestDist) {
20655
- bestDist = d;
20656
- nearest = s;
20657
- }
20658
- }
20659
- return nearest ? {
20660
- fromMs: nearest.startMs,
20661
- toMs: nearest.endMs
20662
- } : null;
20663
- }
20664
- /**
20665
- * Position `ms` inside the window `[fromMs, toMs]` as a clamped percentage
20666
- * (0–100). A zero/negative-width window yields 0. Callers use this for `left:`.
20667
- */
20668
- function pctForMs(ms, fromMs, toMs) {
20669
- const span = toMs - fromMs;
20670
- if (span <= 0) return 0;
20671
- const pct = (ms - fromMs) / span * 100;
20672
- if (pct < 0) return 0;
20673
- if (pct > 100) return 100;
20674
- return pct;
20675
- }
20676
- /**
20677
- * Build a DayWindow for the given calendar `date` using local time.
20678
- * For any day strictly in the past: `fromMs` = local midnight, `toMs` =
20679
- * next local midnight (exactly 24 h).
20680
- * For today (or any future day that contains `nowMs`): `toMs = nowMs` so the
20681
- * window never extends into the future.
20682
- */
20683
- function dayWindow(date, nowMs) {
20684
- const fromMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0).getTime();
20685
- const nextMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1, 0, 0, 0, 0).getTime();
20686
- return {
20687
- fromMs,
20688
- toMs: Math.max(fromMs, Math.min(nextMidnight, nowMs))
20689
- };
20690
- }
20691
- /** Available zoom levels in ms, from widest (24h) to narrowest (15 min). */
20692
- var ZOOM_LEVELS = [
20693
- 24 * 36e5,
20694
- 12 * 36e5,
20695
- 6 * 36e5,
20696
- 36e5,
20697
- 15 * 6e4
20698
- ];
20699
- /**
20700
- * Produce a `levelMs`-wide window whose RIGHT edge stays anchored at
20701
- * `rightEdgeMs` (clamped within `dayBounds`), rather than centring on the view
20702
- * midpoint. On today's timeline the view's right edge is "now", so zooming this
20703
- * way keeps the most recent footage pinned to the right instead of drifting to
20704
- * a midday range that no longer contains now. Panning then moves back/forward.
20705
- *
20706
- * Falls back to `dayBounds` when `levelMs` meets or exceeds the day span (can't
20707
- * show a wider window than the day holds — e.g. early morning with little
20708
- * elapsed time).
20709
- */
20710
- function zoomWindowAnchoredRight(dayBounds, levelMs, rightEdgeMs) {
20711
- if (levelMs >= dayBounds.toMs - dayBounds.fromMs) return dayBounds;
20712
- const toMs = Math.min(Math.max(rightEdgeMs, dayBounds.fromMs + levelMs), dayBounds.toMs);
20713
- return {
20714
- fromMs: toMs - levelMs,
20715
- toMs
20716
- };
20717
- }
20718
- /**
20719
- * Shift `win` by `deltaMs`, preserving width, clamped within `dayBounds`.
20720
- */
20721
- function panWindow(win, dayBounds, deltaMs) {
20722
- const width = win.toMs - win.fromMs;
20723
- let fromMs = win.fromMs + deltaMs;
20724
- let toMs = win.toMs + deltaMs;
20725
- if (fromMs < dayBounds.fromMs) {
20726
- fromMs = dayBounds.fromMs;
20727
- toMs = fromMs + width;
20728
- }
20729
- if (toMs > dayBounds.toMs) {
20730
- toMs = dayBounds.toMs;
20731
- fromMs = toMs - width;
20732
- }
20733
- return {
20734
- fromMs,
20735
- toMs
20736
- };
20737
- }
20738
- /** Candidate bucket sizes in ms (coarsest first). */
20739
- var BUCKET_CANDIDATES = [
20740
- 15 * 6e4,
20741
- 5 * 6e4,
20742
- 6e4,
20743
- 3e4,
20744
- 15e3,
20745
- 5e3
20746
- ];
20747
- /**
20748
- * Choose a sensible heatmap bucket size for a visible span of `spanMs`,
20749
- * targeting ~96-120 buckets. Always picks from `BUCKET_CANDIDATES`.
20750
- */
20751
- function bucketMsForSpan(spanMs) {
20752
- const targetBucket = spanMs / 100;
20753
- for (const candidate of BUCKET_CANDIDATES) if (candidate <= targetBucket) return candidate;
20754
- return BUCKET_CANDIDATES[BUCKET_CANDIDATES.length - 1] ?? 5e3;
20755
- }
20756
- /**
20757
- * Produce `count` evenly-spaced axis ticks spanning [fromMs, toMs].
20758
- * - `pct` is via pctForMs (first=0, last=100).
20759
- * - `label` is a localised HH:MM clock string.
20760
- */
20761
- function axisTicks(fromMs, toMs, count) {
20762
- if (count <= 1) return [{
20763
- pct: 0,
20764
- label: new Date(fromMs).toLocaleTimeString(void 0, {
20765
- hour: "2-digit",
20766
- minute: "2-digit"
20767
- })
20768
- }];
20769
- const ticks = [];
20770
- for (let i = 0; i < count; i++) {
20771
- const ms = fromMs + i / (count - 1) * (toMs - fromMs);
20772
- const pct = pctForMs(ms, fromMs, toMs);
20773
- const label = new Date(ms).toLocaleTimeString(void 0, {
20774
- hour: "2-digit",
20775
- minute: "2-digit"
20776
- });
20777
- ticks.push({
20778
- pct,
20779
- label
20780
- });
20781
- }
20782
- return ticks;
20783
- }
20784
- //#endregion
20785
22204
  //#region src/composites/cap-settings/CoverageTrack.tsx
20786
22205
  function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek, onScrubStart, onScrubMove, onScrubEnd }) {
20787
22206
  const [hoverInfo, setHoverInfo] = (0, react$1.useState)(null);
@@ -20892,42 +22311,6 @@ function CoverageTrack({ windowFrom, windowTo, spans, playheadMs, onSeek, onScru
20892
22311
  });
20893
22312
  }
20894
22313
  //#endregion
20895
- //#region src/composites/cap-settings/EventHeatmap.tsx
20896
- var KIND_BG = {
20897
- motion: "bg-emerald-500",
20898
- object: "bg-violet-500",
20899
- audio: "bg-orange-500"
20900
- };
20901
- function EventHeatmap({ windowFrom, windowTo, buckets, selectedIndex, onSelectBucket }) {
20902
- const peak = buckets.reduce((m, b) => Math.max(m, b.total), 0);
20903
- const firstBucket = buckets[0];
20904
- const bucketWidthPct = firstBucket ? pctForMs(firstBucket.endMs, windowFrom, windowTo) - pctForMs(firstBucket.startMs, windowFrom, windowTo) : 0;
20905
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
20906
- className: "relative h-6 rounded bg-background/40",
20907
- children: buckets.map((b) => {
20908
- if (b.total === 0 || b.dominant === null) return null;
20909
- const heightPct = peak > 0 ? Math.max(15, b.total / peak * 100) : 0;
20910
- const left = pctForMs(b.startMs, windowFrom, windowTo);
20911
- const selected = b.index === selectedIndex;
20912
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
20913
- type: "button",
20914
- "data-testid": "heatmap-bar",
20915
- title: `${new Date(b.startMs).toLocaleTimeString(void 0, {
20916
- hour: "2-digit",
20917
- minute: "2-digit"
20918
- })} · motion ${b.counts.motion} · object ${b.counts.object}`,
20919
- onClick: () => onSelectBucket(b.index),
20920
- className: `absolute bottom-0 rounded-t-sm transition-opacity ${KIND_BG[b.dominant]} ${selected ? "opacity-100 ring-1 ring-amber-400" : "opacity-80 hover:opacity-100"}`,
20921
- style: {
20922
- left: `${left}%`,
20923
- width: `${Math.max(.6, bucketWidthPct)}%`,
20924
- height: `${heightPct}%`
20925
- }
20926
- }, b.index);
20927
- })
20928
- });
20929
- }
20930
- //#endregion
20931
22314
  //#region src/composites/cap-settings/event-thumb.tsx
20932
22315
  function EventThumb({ mediaUrl, kind }) {
20933
22316
  const [failed, setFailed] = (0, react$1.useState)(false);
@@ -21052,142 +22435,6 @@ function EventBucketStrip({ clips, loading, busyKey, onPlay, enabledKinds, onTog
21052
22435
  });
21053
22436
  }
21054
22437
  //#endregion
21055
- //#region src/composites/cap-settings/TimelineControls.tsx
21056
- /** Format a Date as "YYYY-MM-DD" for <input type="date">. */
21057
- function formatDateInput(date) {
21058
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
21059
- }
21060
- /** Parse "YYYY-MM-DD" from <input type="date"> into a local-midnight Date. */
21061
- function parseDateInput(value) {
21062
- const parts = value.split("-");
21063
- const y = parts[0] !== void 0 ? parseInt(parts[0], 10) : NaN;
21064
- const m = parts[1] !== void 0 ? parseInt(parts[1], 10) : NaN;
21065
- const d = parts[2] !== void 0 ? parseInt(parts[2], 10) : NaN;
21066
- if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
21067
- return new Date(y, m - 1, d);
21068
- }
21069
- /**
21070
- * Date selector + prev/next pan + zoom-in / zoom-out buttons for the recording
21071
- * timeline. Zoom cycles through ZOOM_LEVELS (widest → narrowest), anchored on
21072
- * the view's RIGHT edge — so on today's timeline "now" stays pinned to the right
21073
- * instead of drifting to a midday range. The ◀ / ▶ buttons step the visible
21074
- * window earlier/later within the day (the discoverable equivalent of the
21075
- * horizontal wheel-pan wired in RecordingTimeline), so a zoomed-in day can be
21076
- * navigated back and forth without a mouse wheel.
21077
- */
21078
- function TimelineControls({ day, onDayChange, view, dayBounds, onViewChange }) {
21079
- const handleDateChange = (e) => {
21080
- const parsed = parseDateInput(e.target.value);
21081
- if (parsed !== null) onDayChange(parsed);
21082
- };
21083
- const viewSpan = view.toMs - view.fromMs;
21084
- const currentLevelIdx = (() => {
21085
- let best = 0;
21086
- let bestDiff = Infinity;
21087
- for (let i = 0; i < ZOOM_LEVELS.length; i++) {
21088
- const level = ZOOM_LEVELS[i];
21089
- if (level === void 0) continue;
21090
- const diff = Math.abs(level - viewSpan);
21091
- if (diff < bestDiff) {
21092
- bestDiff = diff;
21093
- best = i;
21094
- }
21095
- }
21096
- return best;
21097
- })();
21098
- const rightEdgeMs = view.toMs;
21099
- const handleZoomIn = () => {
21100
- const nextLevel = ZOOM_LEVELS[Math.min(currentLevelIdx + 1, ZOOM_LEVELS.length - 1)];
21101
- if (nextLevel === void 0) return;
21102
- onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
21103
- };
21104
- const handleZoomOut = () => {
21105
- const nextLevel = ZOOM_LEVELS[Math.max(currentLevelIdx - 1, 0)];
21106
- if (nextLevel === void 0) return;
21107
- onViewChange(zoomWindowAnchoredRight(dayBounds, nextLevel, rightEdgeMs));
21108
- };
21109
- const canZoomIn = currentLevelIdx < ZOOM_LEVELS.length - 1;
21110
- const canZoomOut = currentLevelIdx > 0;
21111
- const panStepMs = Math.max(1, Math.round(viewSpan * .9));
21112
- const canPanPrev = view.fromMs > dayBounds.fromMs;
21113
- const canPanNext = view.toMs < dayBounds.toMs;
21114
- const handlePanPrev = () => {
21115
- onViewChange(panWindow(view, dayBounds, -panStepMs));
21116
- };
21117
- const handlePanNext = () => {
21118
- onViewChange(panWindow(view, dayBounds, panStepMs));
21119
- };
21120
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
21121
- className: "flex items-center gap-2",
21122
- children: [
21123
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
21124
- type: "date",
21125
- value: formatDateInput(day),
21126
- max: formatDateInput(/* @__PURE__ */ new Date()),
21127
- onChange: handleDateChange,
21128
- className: "rounded border border-border bg-background px-2 py-0.5 text-[11px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary",
21129
- "aria-label": "Select recording day"
21130
- }),
21131
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
21132
- className: "ml-auto flex items-center gap-1",
21133
- children: [
21134
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21135
- type: "button",
21136
- onClick: handlePanPrev,
21137
- disabled: !canPanPrev,
21138
- title: "Earlier (pan back)",
21139
- "aria-label": "Pan earlier",
21140
- className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
21141
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { size: 14 })
21142
- }),
21143
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21144
- type: "button",
21145
- onClick: handlePanNext,
21146
- disabled: !canPanNext,
21147
- title: "Later (pan forward)",
21148
- "aria-label": "Pan later",
21149
- className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
21150
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { size: 14 })
21151
- }),
21152
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
21153
- className: "mx-0.5 h-3 w-px bg-border",
21154
- "aria-hidden": "true"
21155
- }),
21156
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21157
- type: "button",
21158
- onClick: handleZoomOut,
21159
- disabled: !canZoomOut,
21160
- title: "Zoom out",
21161
- "aria-label": "Zoom out",
21162
- className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
21163
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomOut, { size: 13 })
21164
- }),
21165
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
21166
- type: "button",
21167
- onClick: handleZoomIn,
21168
- disabled: !canZoomIn,
21169
- title: "Zoom in",
21170
- "aria-label": "Zoom in",
21171
- className: "rounded p-1 text-foreground-subtle transition-colors hover:bg-background/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",
21172
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomIn, { size: 13 })
21173
- })
21174
- ]
21175
- }),
21176
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
21177
- className: "text-[9px] text-foreground-subtle",
21178
- children: zoomLabel(viewSpan)
21179
- })
21180
- ]
21181
- });
21182
- }
21183
- function zoomLabel(spanMs) {
21184
- const h = spanMs / 36e5;
21185
- if (h >= 24) return "24h";
21186
- if (h >= 1) return `${Math.round(h)}h`;
21187
- const m = spanMs / 6e4;
21188
- return `${Math.round(m)}m`;
21189
- }
21190
- //#endregion
21191
22438
  //#region src/composites/cap-settings/RecordingTimeline.tsx
21192
22439
  var ALL_KINDS = [
21193
22440
  "motion",
@@ -21583,15 +22830,6 @@ function RecordingTimeline({ deviceId }) {
21583
22830
  //#endregion
21584
22831
  //#region src/composites/cap-settings/RecordingPanel.tsx
21585
22832
  var DAY_MS$1 = 1440 * 60 * 1e3;
21586
- function formatDuration$1(ms) {
21587
- const totalSec = Math.round(ms / 1e3);
21588
- const h = Math.floor(totalSec / 3600);
21589
- const m = Math.floor(totalSec % 3600 / 60);
21590
- const s = totalSec % 60;
21591
- if (h > 0) return `${h}h ${m}m`;
21592
- if (m > 0) return `${m}m ${s}s`;
21593
- return `${s}s`;
21594
- }
21595
22833
  function RecordingPanel({ deviceId }) {
21596
22834
  const queryClient = (0, _tanstack_react_query.useQueryClient)();
21597
22835
  const statusQuery = useRecordingGetStatus({ deviceId });
@@ -21691,7 +22929,7 @@ function RecordingPanel({ deviceId }) {
21691
22929
  label: "Recorded (24h)",
21692
22930
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Clock, { className: "h-3.5 w-3.5" }),
21693
22931
  footer: `${ranges.length} segment range${ranges.length === 1 ? "" : "s"}`,
21694
- children: availabilityQuery.isLoading ? "…" : formatDuration$1(recordedMs)
22932
+ children: availabilityQuery.isLoading ? "…" : formatDurationMs(recordedMs)
21695
22933
  })
21696
22934
  ]
21697
22935
  }),
@@ -21721,7 +22959,7 @@ var HOST_WIDGETS = {
21721
22959
  "host/ptz-autotrack": AutotrackSection,
21722
22960
  "host/recording-panel": RecordingPanel,
21723
22961
  "host/camera-grid-layout": CameraGridLayoutEditor,
21724
- "host/clips-browser": ClipsBrowser
22962
+ "host/clips-browser": ClipsSection
21725
22963
  };
21726
22964
  //#endregion
21727
22965
  //#region src/composites/widget-slot.tsx
@@ -26028,92 +27266,6 @@ function AgentStepEditor(props) {
26028
27266
  });
26029
27267
  }
26030
27268
  //#endregion
26031
- //#region ../../node_modules/class-variance-authority/dist/index.mjs
26032
- /**
26033
- * Copyright 2022 Joe Bell. All rights reserved.
26034
- *
26035
- * This file is licensed to you under the Apache License, Version 2.0
26036
- * (the "License"); you may not use this file except in compliance with the
26037
- * License. You may obtain a copy of the License at
26038
- *
26039
- * http://www.apache.org/licenses/LICENSE-2.0
26040
- *
26041
- * Unless required by applicable law or agreed to in writing, software
26042
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
26043
- * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the
26044
- * License for the specific language governing permissions and limitations under
26045
- * the License.
26046
- */ var falsyToString = (value) => typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value;
26047
- var cx = clsx;
26048
- var cva = (base, config) => (props) => {
26049
- var _config_compoundVariants;
26050
- 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);
26051
- const { variants, defaultVariants } = config;
26052
- const getVariantClassNames = Object.keys(variants).map((variant) => {
26053
- const variantProp = props === null || props === void 0 ? void 0 : props[variant];
26054
- const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];
26055
- if (variantProp === null) return null;
26056
- const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);
26057
- return variants[variant][variantKey];
26058
- });
26059
- const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param) => {
26060
- let [key, value] = param;
26061
- if (value === void 0) return acc;
26062
- acc[key] = value;
26063
- return acc;
26064
- }, {});
26065
- 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) => {
26066
- let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;
26067
- return Object.entries(compoundVariantOptions).every((param) => {
26068
- let [key, value] = param;
26069
- return Array.isArray(value) ? value.includes({
26070
- ...defaultVariants,
26071
- ...propsWithoutUndefined
26072
- }[key]) : {
26073
- ...defaultVariants,
26074
- ...propsWithoutUndefined
26075
- }[key] === value;
26076
- }) ? [
26077
- ...acc,
26078
- cvClass,
26079
- cvClassName
26080
- ] : acc;
26081
- }, []), props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
26082
- };
26083
- //#endregion
26084
- //#region src/primitives/button.tsx
26085
- var buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
26086
- variants: {
26087
- variant: {
26088
- primary: "bg-primary text-primary-foreground hover:bg-primary/90",
26089
- secondary: "bg-surface text-foreground hover:bg-surface-hover",
26090
- ghost: "hover:bg-surface-hover text-foreground-muted",
26091
- danger: "bg-danger text-white hover:bg-danger/90",
26092
- outline: "border border-border bg-transparent text-foreground hover:bg-surface-hover"
26093
- },
26094
- size: {
26095
- sm: "h-7 px-2.5 text-xs gap-1.5",
26096
- md: "h-8 px-3 text-sm gap-2",
26097
- lg: "h-9 px-4 text-sm gap-2"
26098
- }
26099
- },
26100
- defaultVariants: {
26101
- variant: "primary",
26102
- size: "sm"
26103
- }
26104
- });
26105
- var Button = (0, react$1.forwardRef)(({ className, variant, size, ...props }, ref) => {
26106
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
26107
- ref,
26108
- className: cn(buttonVariants({
26109
- variant,
26110
- size
26111
- }), className),
26112
- ...props
26113
- });
26114
- });
26115
- Button.displayName = "Button";
26116
- //#endregion
26117
27269
  //#region src/primitives/icon-button.tsx
26118
27270
  var iconButtonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
26119
27271
  variants: {
@@ -26148,55 +27300,6 @@ var IconButton = (0, react$1.forwardRef)(({ className, variant, size, icon: Icon
26148
27300
  });
26149
27301
  IconButton.displayName = "IconButton";
26150
27302
  //#endregion
26151
- //#region src/primitives/badge.tsx
26152
- var badgeVariants = cva("inline-flex items-center rounded-full text-xs font-medium px-2 py-0.5", {
26153
- variants: {
26154
- variant: {
26155
- default: "bg-surface-hover text-foreground",
26156
- success: "bg-success/15 text-success",
26157
- warning: "bg-warning/15 text-warning",
26158
- danger: "bg-danger/15 text-danger",
26159
- info: "bg-info/15 text-info"
26160
- },
26161
- styleVariant: {
26162
- solid: "",
26163
- outline: "border bg-transparent"
26164
- }
26165
- },
26166
- defaultVariants: {
26167
- variant: "default",
26168
- styleVariant: "solid"
26169
- }
26170
- });
26171
- var Badge = (0, react$1.forwardRef)(({ className, variant, style, ...props }, ref) => {
26172
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
26173
- ref,
26174
- className: cn(badgeVariants({
26175
- variant,
26176
- styleVariant: style
26177
- }), className),
26178
- ...props
26179
- });
26180
- });
26181
- Badge.displayName = "Badge";
26182
- //#endregion
26183
- //#region src/primitives/card.tsx
26184
- var cardVariants = cva("rounded-lg p-3", {
26185
- variants: { variant: {
26186
- flat: "bg-surface",
26187
- bordered: "bg-surface border border-border"
26188
- } },
26189
- defaultVariants: { variant: "bordered" }
26190
- });
26191
- var Card = (0, react$1.forwardRef)(({ className, variant, ...props }, ref) => {
26192
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26193
- ref,
26194
- className: cn(cardVariants({ variant }), className),
26195
- ...props
26196
- });
26197
- });
26198
- Card.displayName = "Card";
26199
- //#endregion
26200
27303
  //#region src/primitives/label.tsx
26201
27304
  var Label = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
26202
27305
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
@@ -26225,16 +27328,6 @@ var Separator = (0, react$1.forwardRef)(({ className, orientation, ...props }, r
26225
27328
  });
26226
27329
  Separator.displayName = "Separator";
26227
27330
  //#endregion
26228
- //#region src/primitives/skeleton.tsx
26229
- var Skeleton = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
26230
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26231
- ref,
26232
- className: cn("animate-pulse bg-surface-hover rounded-md h-4 w-full", className),
26233
- ...props
26234
- });
26235
- });
26236
- Skeleton.displayName = "Skeleton";
26237
- //#endregion
26238
27331
  //#region src/primitives/input.tsx
26239
27332
  /**
26240
27333
  * `disabled:opacity-50 disabled:cursor-not-allowed` for the same reason as
@@ -26276,43 +27369,6 @@ var Input = (0, react$1.forwardRef)(({ className, state, leftSlot, rightSlot, ..
26276
27369
  });
26277
27370
  Input.displayName = "Input";
26278
27371
  //#endregion
26279
- //#region src/primitives/select.tsx
26280
- /**
26281
- * A native `<select>` styled to match the rest of the field set.
26282
- *
26283
- * `appearance-none` plus an explicit `bg-surface` / `text-foreground` is what
26284
- * makes it match — and it is also what USED to make a disabled one a lie. A
26285
- * browser greys a disabled control by rendering its own background and text
26286
- * colour; all three of those are overridden here, so a `disabled` dropdown
26287
- * looked exactly like a live one and simply ignored the click. That is the
26288
- * whole of the "the language selector does not open" report of 2026-08-15:
26289
- * the AI section's switch was off, every control under it was correctly inert,
26290
- * and nothing on screen said so.
26291
- *
26292
- * `disabled:` classes belong HERE and not on each call site: there are dozens
26293
- * of `<Select disabled>` in this app and the one that gets forgotten is the one
26294
- * that generates the ticket. Guarded by `disabled-affordance.test.tsx`.
26295
- */
26296
- var Select = (0, react$1.forwardRef)(({ className, options, disabled, ...props }, ref) => {
26297
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
26298
- className: "relative w-full",
26299
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
26300
- ref,
26301
- disabled,
26302
- 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),
26303
- ...props,
26304
- children: options.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
26305
- value: option.value,
26306
- children: option.label
26307
- }, option.value))
26308
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
26309
- className: cn("absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-foreground-muted", disabled === true ? "opacity-50" : ""),
26310
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3.5 w-3.5" })
26311
- })]
26312
- });
26313
- });
26314
- Select.displayName = "Select";
26315
- //#endregion
26316
27372
  //#region src/primitives/checkbox.tsx
26317
27373
  var Checkbox = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
26318
27374
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -26601,53 +27657,6 @@ function TooltipContent({ className, children, ...props }) {
26601
27657
  });
26602
27658
  }
26603
27659
  //#endregion
26604
- //#region src/hooks/use-is-mobile.ts
26605
- var MOBILE_QUERY = "(max-width: 767px)";
26606
- /**
26607
- * Mid breakpoint — below this width the sidebar collapses to an
26608
- * icons-only rail to reclaim canvas space. Above it (and outside
26609
- * mobile) the full sidebar renders. Threshold matches Tailwind `lg`
26610
- * so sidebar fans out at the same point grids switch to multi-column.
26611
- */
26612
- var MID_QUERY = "(min-width: 768px) and (max-width: 1023px)";
26613
- /**
26614
- * `matchMedia` is universal in browsers but absent in bare DOM environments.
26615
- * These hooks are called from shared primitives (`DataTable` picks its narrow
26616
- * layout with one), so throwing here takes down the whole page that merely
26617
- * rendered a table. Where the capability is missing, report the desktop
26618
- * layout — a widescreen rendering is wrong-looking; a crashed page is gone.
26619
- */
26620
- function matchQuery(query) {
26621
- if (typeof window.matchMedia !== "function") return null;
26622
- return window.matchMedia(query);
26623
- }
26624
- function subscribeQuery(query) {
26625
- return (callback) => {
26626
- const mql = matchQuery(query);
26627
- if (mql === null) return () => {};
26628
- mql.addEventListener("change", callback);
26629
- return () => mql.removeEventListener("change", callback);
26630
- };
26631
- }
26632
- function getSnapshot(query) {
26633
- return () => matchQuery(query)?.matches ?? false;
26634
- }
26635
- function getServerSnapshot() {
26636
- return false;
26637
- }
26638
- function useIsMobile() {
26639
- return (0, react$1.useSyncExternalStore)(subscribeQuery(MOBILE_QUERY), getSnapshot(MOBILE_QUERY), getServerSnapshot);
26640
- }
26641
- /**
26642
- * True when the viewport is in the medium (`md`) Tailwind range —
26643
- * 768px ≤ width < 1024px. Use to drive layouts that should pack denser
26644
- * than full-desktop but more legibly than mobile (e.g. icons-only
26645
- * sidebar rail, narrower stat tiles).
26646
- */
26647
- function useIsMidWidth() {
26648
- return (0, react$1.useSyncExternalStore)(subscribeQuery(MID_QUERY), getSnapshot(MID_QUERY), getServerSnapshot);
26649
- }
26650
- //#endregion
26651
27660
  //#region src/primitives/bottom-sheet.tsx
26652
27661
  function BottomSheet({ open, onClose, title, children, className }) {
26653
27662
  (0, react$1.useEffect)(() => {
@@ -29855,650 +30864,230 @@ function coverageLine(monitor) {
29855
30864
  const extra = [...covered].filter((c) => !SCENE_CAPTURE_VARIANTS.some((v) => v === c));
29856
30865
  for (const c of extra) parts.push(`${variantLabel(c)} ✓`);
29857
30866
  const missing = SCENE_CAPTURE_VARIANTS.filter((v) => !covered.has(v));
29858
- if (missing.length === 0) return parts.join(" · ");
29859
- if (covered.size === 0) return `${parts.join(" · ")} — nothing captured yet, so this scene is not watching.`;
29860
- const names = missing.map((v) => variantLabel(v).toLowerCase()).join(" and ");
29861
- return monitor.onUncoveredCondition === "judge-anyway" ? `${parts.join(" · ")} — ${names} judged against the references you do have.` : `${parts.join(" · ")} — optional; checks in ${names} are skipped.`;
29862
- }
29863
- /**
29864
- * The calm sentence for a scene that is sitting the current light out — `null`
29865
- * whenever it is checking normally.
29866
- *
29867
- * Separate from {@link statusLine} because it is not a verdict and must not
29868
- * read like one. A paused scene has not failed, has not lost its flag, and
29869
- * needs nothing from the operator; the only thing worth saying is which light
29870
- * it is waiting out and that it will pick itself back up.
29871
- */
29872
- function suspensionLine(monitor) {
29873
- if (monitor.suspendedCondition === null) return null;
29874
- const light = variantLabel(monitor.suspendedCondition).toLowerCase();
29875
- 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.`;
29876
- }
29877
- var UNAVAILABLE_SENTENCE = {
29878
- "no-reference-for-condition": "can’t tell — nothing has been captured for this scene yet. Press “Capture daylight” to photograph what normal looks like.",
29879
- "view-shifted": "can’t tell — the camera view has moved since the reference was captured. Re-capture it.",
29880
- "no-vision-profile": "can’t tell — the vision model is unreachable, so the change is on hold.",
29881
- "encoder-model-changed": "can’t tell — the embedding model changed, so the stored references are unreadable. Re-capture them.",
29882
- "no-snapshot": "can’t tell — this camera did not return a frame."
29883
- };
29884
- /**
29885
- * The one line under a scene's name. It answers, in order: can it judge, what
29886
- * does it say, and how long has it said it.
29887
- */
29888
- function statusLine(monitor, now) {
29889
- if (monitor.states.length === 0) return "Not armed yet — capture the initial state to start watching.";
29890
- if (monitor.verdict === "unknown") return monitor.unavailable !== null ? UNAVAILABLE_SENTENCE[monitor.unavailable] : "can’t tell right now.";
29891
- const since = monitor.verdict === "diverged" ? monitor.divergedAt : monitor.armedAt;
29892
- const held = since !== null ? ` for ${humanDuration(Math.max(0, now - since))}` : "";
29893
- 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.`;
29894
- const paused = suspensionLine(monitor);
29895
- if (paused !== null) return paused;
29896
- return monitor.verdict === "diverged" ? `CHANGED${held}` : `Normal${held}`;
29897
- }
29898
- /** The badge word. `latched` wins over `verdict`, because the latch is the
29899
- * thing the operator asked to be told and it outlives the live state.
29900
- *
29901
- * A scene sitting out an uncovered light reads `Paused`, in the NEUTRAL tone —
29902
- * never `alarm`, and never the `danger` variant the page maps that to. A
29903
- * missing IR reference must not be able to paint a red badge on a scene that
29904
- * is doing exactly what it was configured to do. */
29905
- function badgeFor(monitor) {
29906
- if (monitor.states.length === 0) return {
29907
- text: "Not armed",
29908
- tone: "unknown"
29909
- };
29910
- if (monitor.latched) return {
29911
- text: "Changed",
29912
- tone: "alarm"
29913
- };
29914
- if (monitor.suspendedCondition !== null) return {
29915
- text: "Paused",
29916
- tone: "unknown"
29917
- };
29918
- if (monitor.verdict === "unknown") return {
29919
- text: "Can’t tell",
29920
- tone: "unknown"
29921
- };
29922
- return monitor.verdict === "diverged" ? {
29923
- text: "Changed",
29924
- tone: "alarm"
29925
- } : {
29926
- text: "Normal",
29927
- tone: "ok"
29928
- };
29929
- }
29930
- /** A rect covering most of the frame is dominated by weather, sky and parked
29931
- * cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
29932
- * failure is silent INSENSITIVITY, which is the worst kind — hence a warning
29933
- * rather than a refusal. */
29934
- var ROI_AREA_WARN_FRACTION = .4;
29935
- function roiTooLarge(roi) {
29936
- return roi.width * roi.height > ROI_AREA_WARN_FRACTION;
29937
- }
29938
- function humanDuration(ms) {
29939
- const s = Math.floor(ms / 1e3);
29940
- if (s < 60) return `${s}s`;
29941
- const m = Math.floor(s / 60);
29942
- if (m < 60) return `${m}m`;
29943
- const h = Math.floor(m / 60);
29944
- if (h < 48) return `${h}h`;
29945
- return `${Math.floor(h / 24)}d`;
29946
- }
29947
- //#endregion
29948
- //#region src/composites/code-block.tsx
29949
- function CodeBlock({ children, maxHeight = 300, className }) {
29950
- const [copied, setCopied] = (0, react$1.useState)(false);
29951
- const handleCopy = (0, react$1.useCallback)(() => {
29952
- navigator.clipboard.writeText(children).then(() => {
29953
- setCopied(true);
29954
- setTimeout(() => setCopied(false), 2e3);
29955
- });
29956
- }, [children]);
29957
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
29958
- className: cn("relative group", className),
29959
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScrollArea, {
29960
- style: { maxHeight },
29961
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
29962
- className: "font-mono text-xs bg-surface p-3 rounded-md border border-border-subtle",
29963
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children })
29964
- })
29965
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
29966
- className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",
29967
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
29968
- icon: copied ? Check : Copy,
29969
- "aria-label": "Copy code",
29970
- variant: "ghost",
29971
- size: "sm",
29972
- onClick: handleCopy
29973
- })
29974
- })]
29975
- });
29976
- }
29977
- //#endregion
29978
- //#region src/composites/confirm-action-button.tsx
29979
- /**
29980
- * ConfirmActionButton — destructive-action button with a modal confirm.
29981
- *
29982
- * Two-step UX for any operation operators shouldn't trigger by accident:
29983
- * reboot device, restart addon, regenerate token, etc. The trigger is a
29984
- * normal Button that opens a Dialog; confirming runs the async action,
29985
- * the trigger spinner-disables itself for the duration, and the dialog
29986
- * closes on success. Errors surface inline at the dialog's bottom.
29987
- *
29988
- * Generic on `TResult` so callers don't have to discard the return
29989
- * value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
29990
- * for reboot, RefreshCw for restart).
29991
- */
29992
- function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
29993
- const [open, setOpen] = (0, react$1.useState)(false);
29994
- const [running, setRunning] = (0, react$1.useState)(false);
29995
- const [error, setError] = (0, react$1.useState)(null);
29996
- const handleConfirm = async () => {
29997
- setError(null);
29998
- setRunning(true);
29999
- try {
30000
- const result = await action();
30001
- onSuccess?.(result);
30002
- setOpen(false);
30003
- } catch (err) {
30004
- setError(err instanceof Error ? err.message : String(err));
30005
- } finally {
30006
- setRunning(false);
30007
- }
30008
- };
30009
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
30010
- type: "button",
30011
- variant: triggerVariant,
30012
- size,
30013
- disabled,
30014
- onClick: () => {
30015
- setError(null);
30016
- setOpen(true);
30017
- },
30018
- className,
30019
- children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-3.5 w-3.5" }), label]
30020
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
30021
- open,
30022
- onOpenChange: (next) => !running && setOpen(next),
30023
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, { children: [
30024
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogHeader, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogTitle, {
30025
- className: "flex items-center gap-2",
30026
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
30027
- }), typeof description === "string" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DialogDescription, { children: description }) : description] }),
30028
- error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30029
- className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
30030
- children: error
30031
- }),
30032
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
30033
- type: "button",
30034
- variant: "ghost",
30035
- size,
30036
- disabled: running,
30037
- onClick: () => setOpen(false),
30038
- children: "Cancel"
30039
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
30040
- type: "button",
30041
- variant: confirmVariant,
30042
- size,
30043
- disabled: running,
30044
- onClick: () => {
30045
- handleConfirm();
30046
- },
30047
- children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
30048
- })] })
30049
- ] })
30050
- })] });
30051
- }
30052
- //#endregion
30053
- //#region src/composites/copy-button.tsx
30054
- /**
30055
- * `CopyButton` — a compact button that copies a string `value` to the
30056
- * clipboard via `navigator.clipboard.writeText`, showing a brief
30057
- * "copied" check state. Addon-agnostic; used wherever a secret / URL
30058
- * needs a one-click copy affordance (export setup panels, etc.).
30059
- */
30060
- var COPIED_RESET_MS = 2e3;
30061
- function CopyButton({ value, label, srLabel, className, disabled }) {
30062
- const [copied, setCopied] = (0, react$1.useState)(false);
30063
- const handleCopy = (0, react$1.useCallback)(() => {
30064
- if (!value) return;
30065
- navigator.clipboard.writeText(value).then(() => {
30066
- setCopied(true);
30067
- setTimeout(() => setCopied(false), COPIED_RESET_MS);
30068
- });
30069
- }, [value]);
30070
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
30071
- size: "sm",
30072
- variant: "ghost",
30073
- type: "button",
30074
- disabled: disabled || value.length === 0,
30075
- onClick: handleCopy,
30076
- className: cn(className),
30077
- "aria-label": copied ? "Copied" : `Copy ${srLabel ?? label ?? "value"}`,
30078
- children: [copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Copy, { className: "h-3.5 w-3.5" }), label ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30079
- className: "ml-1",
30080
- children: copied ? "Copied" : label
30081
- }) : null]
30082
- });
30083
- }
30084
- //#endregion
30085
- //#region src/composites/data-table-layout.ts
30086
- /**
30087
- * At this many columns and above, `auto` switches a narrow viewport to cards.
30088
- * Three columns still fit a phone at a readable size; four do not.
30089
- */
30090
- var CARD_MODE_MIN_COLUMNS = 4;
30091
- function resolveTableLayout({ mode, columnCount, isNarrow }) {
30092
- if (!isNarrow) return "table";
30093
- if (mode === "scroll") return "table";
30094
- if (mode === "cards") return "cards";
30095
- if (columnCount === 0) return "table";
30096
- return columnCount >= 4 ? "cards" : "table";
30097
- }
30098
- //#endregion
30099
- //#region src/lib/responsive.ts
30100
- /**
30101
- * Responsive design tokens — three-tier system.
30102
- *
30103
- * The admin-ui targets three width tiers; every component should pick
30104
- * the right token from this file instead of hand-coding breakpoints.
30105
- * Adopting the tokens uniformly is what makes the app feel consistent
30106
- * across operator devices (mobile / tablet / desktop) and what unlocks
30107
- * targeted tuning (e.g. shrink every label by 1px in one place).
30108
- *
30109
- * Tier definitions (Tailwind breakpoints):
30110
- *
30111
- * • **L1 — Compact** (`< 640px`, default = mobile portrait)
30112
- * Single column, tight padding, smallest text, icon-only buttons
30113
- * where labels can be inferred. The operator can still see every
30114
- * stat / action — no horizontal scroll, no clipped text. The user
30115
- * directive: "molta informazione in poco spazio quando la width è
30116
- * bassa."
30117
- *
30118
- * • **L2 — Standard** (`sm` — `640–1023px`, tablet + small laptop)
30119
- * 2-column grids, side panels become visible, button labels return.
30120
- * Targets ~768px–1024px viewports — the most common operator sizes.
30121
- *
30122
- * • **L3 — Spacious** (`lg+` — `≥ 1024px`, desktop)
30123
- * 4-column grids, generous padding, full-width header text,
30124
- * side-by-side composite layouts. The operator has elbow room.
30125
- *
30126
- * Conventions:
30127
- * - **Always start from L1** — the bare class is always the compact
30128
- * variant, then `sm:` adds room at 640px, `lg:` at 1024px. No
30129
- * desktop-first breakpoints; no max-width queries.
30130
- * - **Token strings are static** — kept as `as const` so callers can
30131
- * compose them via `cn()` / template literals without running a
30132
- * class generator at runtime.
30133
- * - **Pair the token with a comment** — the comment in the file
30134
- * names every breakpoint it crosses, so a reader can predict the
30135
- * layout without going to Tailwind docs.
30136
- */
30137
- /** Container padding for a card-style section (header + body). */
30138
- var SECTION_CARD = "rounded-lg border border-border bg-surface overflow-hidden";
30139
- /** Header strip inside a card (between border-b lines). */
30140
- var SECTION_HEADER = "border-b border-border px-3 sm:px-4 py-1.5 sm:py-2";
30141
- /** Body padding for a card-style section. */
30142
- var SECTION_BODY = "p-2 sm:p-3 lg:p-4";
30143
- /** Gap between cards in a vertical stack. */
30144
- var STACK_GAP = "space-y-3 sm:space-y-4";
30145
- /** Gap between cards in a grid (4 cells / row at lg). */
30146
- var GRID_GAP = "gap-2 sm:gap-3";
30147
- /** Row padding inside a divided list (e.g. InfoRow). */
30148
- var LIST_ROW = "px-3 sm:px-4 py-1.5 sm:py-2";
30149
- /** 1-up at L1, 2-up at L2, 4-up at L3 — quick-stat row. */
30150
- var GRID_QUICK_STATS = "grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4";
30151
- /** 1-up at L1, 2-up at L2+ — paired panels (motion + detection rules). */
30152
- var GRID_PAIRED = "grid grid-cols-1 lg:grid-cols-2";
30153
- /** Side-by-side layout that stacks below md (canvas + side panel). */
30154
- var SPLIT_PANEL_OUTER = "flex flex-col-reverse md:flex-row";
30155
- /** Sidebar narrow lane in a SPLIT_PANEL_OUTER layout. */
30156
- 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";
30157
- /**
30158
- * The tier boundary at which a settings label/value row stops stacking.
30159
- *
30160
- * Below it (L1 — compact) the row is ONE column: label on its own line, value
30161
- * on the next at full width. Above it the row is label-left / value-right as
30162
- * before. The two-column form needs `SETTING_ROW_LABEL`'s lane plus a readable
30163
- * value beside it; a phone has neither, and the failure mode is not "cramped"
30164
- * but "the value is the part that gets cut" — the one thing the operator
30165
- * opened the page to read.
30166
- */
30167
- var SETTING_ROW_STACK_BREAKPOINT = "sm";
30168
- /** Outer row: single column at L1, label/value columns from `sm` up. */
30169
- var SETTING_ROW = "flex min-w-0 flex-col gap-0.5 py-1.5 sm:flex-row sm:items-center";
30170
- /** Label lane. Full width (and free to wrap) at L1; a fixed lane from `sm` up. */
30171
- var SETTING_ROW_LABEL = "min-w-0 text-[11px] leading-tight text-foreground-subtle sm:w-32 sm:shrink-0 sm:pr-2";
30172
- /**
30173
- * Value lane — the value text plus its trailing affordances (copy / reveal).
30174
- * `w-full` at L1 so the value owns the whole row once the label is above it;
30175
- * `sm:flex-1` makes it share the row again from `sm` up.
30176
- */
30177
- var SETTING_ROW_VALUE = "flex w-full min-w-0 items-center gap-1 sm:flex-1";
30178
- /**
30179
- * The value text itself. `break-all` lets an opaque token (a key, a URL) wrap
30180
- * at L1 instead of being cut mid-word; `sm:truncate` restores the single-line
30181
- * desktop form, where the row is wide enough for truncation to be a choice
30182
- * rather than data loss.
30183
- */
30184
- var SETTING_ROW_VALUE_TEXT = "min-w-0 break-all text-xs text-foreground sm:truncate";
30185
- /** Section header label (uppercase tracking-wider). */
30186
- var TEXT_SECTION_LABEL = "text-[10px] sm:text-[11px] font-semibold text-foreground uppercase tracking-wider";
30187
- /** Field label inside a row. */
30188
- var TEXT_FIELD_LABEL = "text-[11px] sm:text-xs text-foreground";
30189
- /** Sub-label / hint text. */
30190
- var TEXT_HINT = "text-[10px] text-foreground-subtle";
30191
- /** Primary value (a metric, badge, …). */
30192
- var TEXT_VALUE = "text-[11px] sm:text-xs font-medium text-foreground";
30193
- /** Big metric value (StatCard). */
30194
- var TEXT_METRIC = "text-base sm:text-lg font-semibold leading-tight text-foreground";
30195
- /** Default compact button. */
30196
- 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";
30197
- /** Primary compact button. */
30198
- 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";
30199
- /** Danger compact button. */
30200
- 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";
30201
- /** Warning compact button (e.g. cancel-drawing). */
30202
- 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";
30203
- /** Compact text/select/number input. */
30204
- 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";
30205
- /** Chip-style toggle (zone / class selectors in ZoneRulesEditor). */
30206
- var CHIP_BASE = "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors";
30207
- var CHIP_ACTIVE = "border-primary/50 bg-primary/15 text-primary";
30208
- var CHIP_INACTIVE = "border-border bg-surface text-foreground-subtle hover:bg-surface-hover";
30209
- //#endregion
30210
- //#region src/composites/setting-row.tsx
30211
- function SettingRow({ label, children, actions, elements = "plain", className, valueClassName }) {
30212
- const LabelTag = elements === "description" ? "dt" : "span";
30213
- const ValueTag = elements === "description" ? "dd" : "div";
30214
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30215
- "data-setting-row": "",
30216
- className: cn(SETTING_ROW, className),
30217
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelTag, {
30218
- "data-setting-row-label": "",
30219
- className: SETTING_ROW_LABEL,
30220
- children: label
30221
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ValueTag, {
30222
- "data-setting-row-value": "",
30223
- className: SETTING_ROW_VALUE,
30224
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30225
- "data-setting-row-value-text": "",
30226
- className: cn(SETTING_ROW_VALUE_TEXT, valueClassName),
30227
- children
30228
- }), actions !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30229
- className: "flex shrink-0 items-center gap-0.5",
30230
- children: actions
30231
- })]
30232
- })]
30233
- });
30234
- }
30235
- //#endregion
30236
- //#region src/composites/sort-header-button.tsx
30237
- function SortHeaderButton({ label, active, dir, onSort }) {
30238
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
30239
- type: "button",
30240
- onClick: onSort,
30241
- className: "group/sort inline-flex items-center gap-0.5 hover:text-foreground transition-colors",
30242
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), active ? dir === "asc" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: cn("h-3 w-3 flex-shrink-0 opacity-0 transition-opacity", "group-hover/sort:opacity-30") })]
30243
- });
30244
- }
30245
- //#endregion
30246
- //#region src/composites/table-sort.ts
30247
- /**
30248
- * The toggle rule, and the only copy of it.
30249
- *
30250
- * none → asc → desc → none, and a click on a DIFFERENT column starts that
30251
- * column fresh at `asc`. `sameColumn` says whether the clicked column is the
30252
- * one currently sorted; `currentDir` is its direction (`null` when nothing is
30253
- * sorted). Returns the next direction, or `null` for "unsorted".
30254
- *
30255
- * Split out from {@link nextTableSort} so the device list — whose sort state
30256
- * keys columns by `columnId`, not `columnKey` — can reuse the rule without a
30257
- * cast and without a second copy of it.
30258
- */
30259
- function nextSortDirection(sameColumn, currentDir) {
30260
- if (!sameColumn || currentDir === null) return "asc";
30261
- return currentDir === "asc" ? "desc" : null;
30262
- }
30263
- /**
30264
- * Toggle a column's sort on header click: none → asc → desc → none.
30265
- * Clicking a DIFFERENT column starts it fresh at `asc`.
30266
- */
30267
- function nextTableSort(current, columnKey) {
30268
- const dir = nextSortDirection(current !== null && current.columnKey === columnKey, current === null ? null : current.dir);
30269
- return dir === null ? null : {
30270
- columnKey,
30271
- dir
30272
- };
30867
+ if (missing.length === 0) return parts.join(" · ");
30868
+ if (covered.size === 0) return `${parts.join(" · ")} — nothing captured yet, so this scene is not watching.`;
30869
+ const names = missing.map((v) => variantLabel(v).toLowerCase()).join(" and ");
30870
+ return monitor.onUncoveredCondition === "judge-anyway" ? `${parts.join(" · ")} — ${names} judged against the references you do have.` : `${parts.join(" · ")} — optional; checks in ${names} are skipped.`;
30273
30871
  }
30274
30872
  /**
30275
- * Ascending comparison of two cell values.
30276
- *
30277
- * Numbers compare numerically and strings compare with `localeCompare` — a
30278
- * numeric column must never be ordered as text ("100" before "9"), and a text
30279
- * column must respect the locale's collation. A mixed column (one row a
30280
- * number, another a string) falls back to string comparison of both, which is
30281
- * the only total order available; it is not a shape any caller should build on
30282
- * purpose.
30873
+ * The calm sentence for a scene that is sitting the current light out — `null`
30874
+ * whenever it is checking normally.
30283
30875
  *
30284
- * `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
30285
- * because its placement is direction-independent and a comparator that is
30286
- * multiplied by the direction factor cannot express that.
30876
+ * Separate from {@link statusLine} because it is not a verdict and must not
30877
+ * read like one. A paused scene has not failed, has not lost its flag, and
30878
+ * needs nothing from the operator; the only thing worth saying is which light
30879
+ * it is waiting out and that it will pick itself back up.
30287
30880
  */
30288
- function compareValues(a, b) {
30289
- if (typeof a === "number" && typeof b === "number") return a - b;
30290
- return String(a).localeCompare(String(b));
30881
+ function suspensionLine(monitor) {
30882
+ if (monitor.suspendedCondition === null) return null;
30883
+ const light = variantLabel(monitor.suspendedCondition).toLowerCase();
30884
+ 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.`;
30291
30885
  }
30886
+ var UNAVAILABLE_SENTENCE = {
30887
+ "no-reference-for-condition": "can’t tell — nothing has been captured for this scene yet. Press “Capture daylight” to photograph what normal looks like.",
30888
+ "view-shifted": "can’t tell — the camera view has moved since the reference was captured. Re-capture it.",
30889
+ "no-vision-profile": "can’t tell — the vision model is unreachable, so the change is on hold.",
30890
+ "encoder-model-changed": "can’t tell — the embedding model changed, so the stored references are unreadable. Re-capture them.",
30891
+ "no-snapshot": "can’t tell — this camera did not return a frame."
30892
+ };
30292
30893
  /**
30293
- * Return a NEW array of `rows` ordered by `sort`.
30294
- *
30295
- * `valueOf` is the column's `sortValue` — it is called once per row per
30296
- * comparison, so it must be cheap (read a field, do not format a string).
30297
- * `sort === null` ⇒ a fresh shallow copy in the incoming order.
30894
+ * The one line under a scene's name. It answers, in order: can it judge, what
30895
+ * does it say, and how long has it said it.
30298
30896
  */
30299
- function sortRowsByColumn(rows, sort, valueOf) {
30300
- if (sort === null) return [...rows];
30301
- const factor = sort.dir === "asc" ? 1 : -1;
30302
- return rows.toSorted((a, b) => {
30303
- const av = valueOf(a);
30304
- const bv = valueOf(b);
30305
- if (av === null || bv === null) {
30306
- if (av === bv) return 0;
30307
- return av === null ? 1 : -1;
30308
- }
30309
- return factor * compareValues(av, bv);
30310
- });
30897
+ function statusLine(monitor, now) {
30898
+ if (monitor.states.length === 0) return "Not armed yet — capture the initial state to start watching.";
30899
+ if (monitor.verdict === "unknown") return monitor.unavailable !== null ? UNAVAILABLE_SENTENCE[monitor.unavailable] : "can’t tell right now.";
30900
+ const since = monitor.verdict === "diverged" ? monitor.divergedAt : monitor.armedAt;
30901
+ const held = since !== null ? ` for ${humanDuration(Math.max(0, now - since))}` : "";
30902
+ 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.`;
30903
+ const paused = suspensionLine(monitor);
30904
+ if (paused !== null) return paused;
30905
+ return monitor.verdict === "diverged" ? `CHANGED${held}` : `Normal${held}`;
30311
30906
  }
30312
- /** Derive the `<th>` `aria-sort` for `columnKey` from the table's sort state. */
30313
- function ariaSortForColumn(sort, columnKey) {
30314
- if (sort === null || sort.columnKey !== columnKey) return "none";
30315
- return sort.dir === "asc" ? "ascending" : "descending";
30907
+ /** The badge word. `latched` wins over `verdict`, because the latch is the
30908
+ * thing the operator asked to be told and it outlives the live state.
30909
+ *
30910
+ * A scene sitting out an uncovered light reads `Paused`, in the NEUTRAL tone —
30911
+ * never `alarm`, and never the `danger` variant the page maps that to. A
30912
+ * missing IR reference must not be able to paint a red badge on a scene that
30913
+ * is doing exactly what it was configured to do. */
30914
+ function badgeFor(monitor) {
30915
+ if (monitor.states.length === 0) return {
30916
+ text: "Not armed",
30917
+ tone: "unknown"
30918
+ };
30919
+ if (monitor.latched) return {
30920
+ text: "Changed",
30921
+ tone: "alarm"
30922
+ };
30923
+ if (monitor.suspendedCondition !== null) return {
30924
+ text: "Paused",
30925
+ tone: "unknown"
30926
+ };
30927
+ if (monitor.verdict === "unknown") return {
30928
+ text: "Can’t tell",
30929
+ tone: "unknown"
30930
+ };
30931
+ return monitor.verdict === "diverged" ? {
30932
+ text: "Changed",
30933
+ tone: "alarm"
30934
+ } : {
30935
+ text: "Normal",
30936
+ tone: "ok"
30937
+ };
30938
+ }
30939
+ /** A rect covering most of the frame is dominated by weather, sky and parked
30940
+ * cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
30941
+ * failure is silent INSENSITIVITY, which is the worst kind — hence a warning
30942
+ * rather than a refusal. */
30943
+ var ROI_AREA_WARN_FRACTION = .4;
30944
+ function roiTooLarge(roi) {
30945
+ return roi.width * roi.height > ROI_AREA_WARN_FRACTION;
30946
+ }
30947
+ function humanDuration(ms) {
30948
+ const s = Math.floor(ms / 1e3);
30949
+ if (s < 60) return `${s}s`;
30950
+ const m = Math.floor(s / 60);
30951
+ if (m < 60) return `${m}m`;
30952
+ const h = Math.floor(m / 60);
30953
+ if (h < 48) return `${h}h`;
30954
+ return `${Math.floor(h / 24)}d`;
30316
30955
  }
30317
30956
  //#endregion
30318
- //#region src/composites/data-table.tsx
30957
+ //#region src/composites/code-block.tsx
30958
+ function CodeBlock({ children, maxHeight = 300, className }) {
30959
+ const [copied, setCopied] = (0, react$1.useState)(false);
30960
+ const handleCopy = (0, react$1.useCallback)(() => {
30961
+ navigator.clipboard.writeText(children).then(() => {
30962
+ setCopied(true);
30963
+ setTimeout(() => setCopied(false), 2e3);
30964
+ });
30965
+ }, [children]);
30966
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30967
+ className: cn("relative group", className),
30968
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScrollArea, {
30969
+ style: { maxHeight },
30970
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
30971
+ className: "font-mono text-xs bg-surface p-3 rounded-md border border-border-subtle",
30972
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children })
30973
+ })
30974
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30975
+ className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",
30976
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
30977
+ icon: copied ? Check : Copy,
30978
+ "aria-label": "Copy code",
30979
+ variant: "ghost",
30980
+ size: "sm",
30981
+ onClick: handleCopy
30982
+ })
30983
+ })]
30984
+ });
30985
+ }
30986
+ //#endregion
30987
+ //#region src/composites/confirm-action-button.tsx
30319
30988
  /**
30320
- * DataTable — shared responsive table for admin pages.
30321
- *
30322
- * Recipes baked in:
30323
- * - `overflow-x-auto` wrapper so narrow viewports don't clip the
30324
- * right edge (the rounded border survives the scroll).
30325
- * - `min-w-[Npx]` on the inner table so columns don't squash below
30326
- * readable width before the scroll kicks in.
30327
- * - `whitespace-nowrap` on headers — column titles never wrap to
30328
- * two lines (`AUTO-KILL` was the regression that triggered this).
30329
- * - Themed `bg-surface` + `border-border` so contrast works in both
30330
- * light and dark themes.
30331
- *
30332
- * Column model: caller supplies `columns: DataColumn<T>[]` and
30333
- * `rows: T[]`. Each column declares `header`, optional `align`,
30334
- * optional `nowrap`, and a `render(row) => ReactNode` that produces
30335
- * the cell content.
30336
- *
30337
- * <DataTable
30338
- * columns={[
30339
- * { key: 'username', header: 'Username', render: (u) => u.username },
30340
- * { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
30341
- * { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
30342
- * ]}
30343
- * rows={users}
30344
- * minWidthPx={560}
30345
- * />
30346
- *
30347
- * If finer header markup is needed, pass `headerRender` instead of
30348
- * `header` (e.g. for a sortable click affordance).
30349
- *
30350
- * ── Narrow viewports ──────────────────────────────────────────────────────
30351
- * The table stays a table. The wrapper is `overflow-x-auto`, so a phone
30352
- * scrolls the columns sideways INSIDE the table — the page body never moves
30353
- * sideways, and the cells stay one line (`whitespace-nowrap`). That is the
30354
- * default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
30355
- * `auto` still applies the old column-count rule for a surface that wants it.
30356
- * See `resolveTableLayout`.
30357
- *
30358
- * ── Scrolling the BODY, not the page ──────────────────────────────────────
30359
- * `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
30360
- * with the column header pinned. It is ONE prop, not two, because "this list
30361
- * scrolls internally" and "its header stays visible" are the same decision: a
30362
- * capped table whose header scrolls away is a list of unlabelled cells.
30989
+ * ConfirmActionButton — destructive-action button with a modal confirm.
30363
30990
  *
30364
- * Use it wherever the row count is open-ended (rules, targets, delivery
30365
- * history) so the page keeps its own header, filters and actions in view while
30366
- * the operator moves through the rows.
30367
- */
30368
- /**
30369
- * The default cap for an internally-scrolling table.
30991
+ * Two-step UX for any operation operators shouldn't trigger by accident:
30992
+ * reboot device, restart addon, regenerate token, etc. The trigger is a
30993
+ * normal Button that opens a Dialog; confirming runs the async action,
30994
+ * the trigger spinner-disables itself for the duration, and the dialog
30995
+ * closes on success. Errors surface inline at the dialog's bottom.
30370
30996
  *
30371
- * `60vh` leaves the page header, its filters and its actions on screen at
30372
- * every viewport while still showing ~10 rows — and it is a fraction, so it
30373
- * does not need a breakpoint of its own.
30997
+ * Generic on `TResult` so callers don't have to discard the return
30998
+ * value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
30999
+ * for reboot, RefreshCw for restart).
30374
31000
  */
30375
- var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
30376
- var ALIGN_CLASS = {
30377
- left: "text-left",
30378
- right: "text-right",
30379
- center: "text-center"
30380
- };
30381
- function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "scroll", maxBodyHeight, defaultSort }) {
30382
- const isNarrow = useIsMobile();
30383
- const [sort, setSort] = (0, react$1.useState)(defaultSort ?? null);
30384
- const layout = resolveTableLayout({
30385
- mode: mobileMode,
30386
- columnCount: columns.length,
30387
- isNarrow
30388
- });
30389
- const scrolls = maxBodyHeight !== void 0;
30390
- const sortedColumn = (0, react$1.useMemo)(() => sort === null ? void 0 : columns.find((col) => col.key === sort.columnKey && col.sortValue !== void 0), [columns, sort]);
30391
- const sortValue = sortedColumn?.sortValue;
30392
- const displayRows = (0, react$1.useMemo)(() => sortValue === void 0 ? rows : sortRowsByColumn(rows, sort, sortValue), [
30393
- rows,
30394
- sort,
30395
- sortValue
30396
- ]);
30397
- const activeSort = sortedColumn === void 0 ? null : sort;
30398
- if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30399
- className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
30400
- children: emptyMessage
30401
- });
30402
- if (rows.length === 0) return null;
30403
- if (layout === "cards") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30404
- className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
30405
- children: displayRows.map((row, rowIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTableCard, {
30406
- row,
30407
- rowIndex,
30408
- columns,
30409
- bordered,
30410
- onRowClick,
30411
- rowClassName
30412
- }, rowKey ? rowKey(row, rowIndex) : rowIndex))
30413
- });
30414
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30415
- className: [
30416
- "rounded-lg",
30417
- bordered ? "border border-border" : "",
30418
- "bg-surface overflow-x-auto",
30419
- scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
30420
- className ?? ""
30421
- ].filter(Boolean).join(" "),
30422
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
30423
- className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
30424
- style: { minWidth: `${minWidthPx}px` },
30425
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", {
30426
- className: "bg-surface",
30427
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: columns.map((col) => {
30428
- const align = col.align ?? "left";
30429
- const nowrap = col.nowrap ?? true;
30430
- const sortable = col.sortValue !== void 0;
30431
- const isSorted = activeSort !== null && activeSort.columnKey === col.key;
30432
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
30433
- "aria-sort": sortable ? ariaSortForColumn(activeSort, col.key) : void 0,
30434
- title: sortable ? void 0 : col.notSortableReason,
30435
- className: [
30436
- "px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
30437
- scrolls ? "sticky top-0 z-10" : "",
30438
- ALIGN_CLASS[align],
30439
- nowrap ? "whitespace-nowrap" : "",
30440
- col.width ?? ""
30441
- ].filter(Boolean).join(" "),
30442
- children: sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SortHeaderButton, {
30443
- label: col.headerRender ? col.headerRender() : col.header,
30444
- active: isSorted,
30445
- dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
30446
- onSort: () => setSort((prev) => nextTableSort(prev, col.key))
30447
- }) : col.headerRender ? col.headerRender() : col.header
30448
- }, col.key);
30449
- }) })
30450
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: displayRows.map((row, rowIndex) => {
30451
- const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
30452
- const extra = rowClassName?.(row, rowIndex) ?? "";
30453
- const interactive = onRowClick !== void 0;
30454
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", {
30455
- onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
30456
- className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
30457
- children: columns.map((col) => {
30458
- const align = col.align ?? "left";
30459
- const nowrap = col.nowrap ?? true;
30460
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
30461
- className: [
30462
- "px-3 py-2 text-foreground border-b border-border",
30463
- ALIGN_CLASS[align],
30464
- nowrap ? "whitespace-nowrap" : ""
30465
- ].filter(Boolean).join(" "),
30466
- children: col.render(row, rowIndex)
30467
- }, col.key);
30468
- })
30469
- }, key);
30470
- }) })]
30471
- })
30472
- });
31001
+ function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
31002
+ const [open, setOpen] = (0, react$1.useState)(false);
31003
+ const [running, setRunning] = (0, react$1.useState)(false);
31004
+ const [error, setError] = (0, react$1.useState)(null);
31005
+ const handleConfirm = async () => {
31006
+ setError(null);
31007
+ setRunning(true);
31008
+ try {
31009
+ const result = await action();
31010
+ onSuccess?.(result);
31011
+ setOpen(false);
31012
+ } catch (err) {
31013
+ setError(err instanceof Error ? err.message : String(err));
31014
+ } finally {
31015
+ setRunning(false);
31016
+ }
31017
+ };
31018
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
31019
+ type: "button",
31020
+ variant: triggerVariant,
31021
+ size,
31022
+ disabled,
31023
+ onClick: () => {
31024
+ setError(null);
31025
+ setOpen(true);
31026
+ },
31027
+ className,
31028
+ children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-3.5 w-3.5" }), label]
31029
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
31030
+ open,
31031
+ onOpenChange: (next) => !running && setOpen(next),
31032
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, { children: [
31033
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogHeader, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogTitle, {
31034
+ className: "flex items-center gap-2",
31035
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
31036
+ }), typeof description === "string" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DialogDescription, { children: description }) : description] }),
31037
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31038
+ className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
31039
+ children: error
31040
+ }),
31041
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
31042
+ type: "button",
31043
+ variant: "ghost",
31044
+ size,
31045
+ disabled: running,
31046
+ onClick: () => setOpen(false),
31047
+ children: "Cancel"
31048
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
31049
+ type: "button",
31050
+ variant: confirmVariant,
31051
+ size,
31052
+ disabled: running,
31053
+ onClick: () => {
31054
+ handleConfirm();
31055
+ },
31056
+ children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
31057
+ })] })
31058
+ ] })
31059
+ })] });
30473
31060
  }
31061
+ //#endregion
31062
+ //#region src/composites/copy-button.tsx
30474
31063
  /**
30475
- * One row as a card. Columns that carry a plain-text `header` become labelled
30476
- * `SettingRow`s — the label is the column title and the value gets the full
30477
- * card width. Columns with no text header (an actions column, a
30478
- * `headerRender`-only column) have no label to show, so they render as a
30479
- * full-width strip at the foot of the card.
31064
+ * `CopyButton` — a compact button that copies a string `value` to the
31065
+ * clipboard via `navigator.clipboard.writeText`, showing a brief
31066
+ * "copied" check state. Addon-agnostic; used wherever a secret / URL
31067
+ * needs a one-click copy affordance (export setup panels, etc.).
30480
31068
  */
30481
- function DataTableCard({ row, rowIndex, columns, bordered, onRowClick, rowClassName }) {
30482
- const labelled = columns.filter((col) => col.header !== void 0 && col.header !== "");
30483
- const unlabelled = columns.filter((col) => col.header === void 0 || col.header === "");
30484
- const interactive = onRowClick !== void 0;
30485
- const extra = rowClassName?.(row, rowIndex) ?? "";
30486
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30487
- "data-data-table-card": "",
30488
- onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
30489
- className: [
30490
- "rounded-lg bg-surface px-3 py-1.5 divide-y divide-border-subtle",
30491
- bordered ? "border border-border" : "",
30492
- interactive ? "cursor-pointer hover:bg-primary/5" : "",
30493
- extra
30494
- ].filter(Boolean).join(" "),
30495
- children: [labelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
30496
- label: col.header,
30497
- children: col.render(row, rowIndex)
30498
- }, col.key)), unlabelled.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30499
- className: "flex flex-wrap items-center justify-end gap-2 py-1.5",
30500
- children: unlabelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: col.render(row, rowIndex) }, col.key))
30501
- })]
31069
+ var COPIED_RESET_MS = 2e3;
31070
+ function CopyButton({ value, label, srLabel, className, disabled }) {
31071
+ const [copied, setCopied] = (0, react$1.useState)(false);
31072
+ const handleCopy = (0, react$1.useCallback)(() => {
31073
+ if (!value) return;
31074
+ navigator.clipboard.writeText(value).then(() => {
31075
+ setCopied(true);
31076
+ setTimeout(() => setCopied(false), COPIED_RESET_MS);
31077
+ });
31078
+ }, [value]);
31079
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
31080
+ size: "sm",
31081
+ variant: "ghost",
31082
+ type: "button",
31083
+ disabled: disabled || value.length === 0,
31084
+ onClick: handleCopy,
31085
+ className: cn(className),
31086
+ "aria-label": copied ? "Copied" : `Copy ${srLabel ?? label ?? "value"}`,
31087
+ children: [copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Copy, { className: "h-3.5 w-3.5" }), label ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31088
+ className: "ml-1",
31089
+ children: copied ? "Copied" : label
31090
+ }) : null]
30502
31091
  });
30503
31092
  }
30504
31093
  //#endregion
@@ -46556,99 +47145,6 @@ function fuzzyMatch(query, target) {
46556
47145
  return normalizeForSearch(target).includes(nq);
46557
47146
  }
46558
47147
  //#endregion
46559
- //#region src/composites/device-list/pagination.tsx
46560
- var NEIGHBORS = 2;
46561
- function computePageButtons(currentPage, totalPages) {
46562
- if (totalPages <= 1) return [{
46563
- kind: "page",
46564
- n: 1
46565
- }];
46566
- if (totalPages <= 7) {
46567
- const all = [];
46568
- for (let i = 1; i <= totalPages; i++) all.push({
46569
- kind: "page",
46570
- n: i
46571
- });
46572
- return all;
46573
- }
46574
- const result = [];
46575
- const leftBound = Math.max(2, currentPage - NEIGHBORS);
46576
- const rightBound = Math.min(totalPages - 1, currentPage + NEIGHBORS);
46577
- result.push({
46578
- kind: "page",
46579
- n: 1
46580
- });
46581
- if (leftBound > 2) result.push({
46582
- kind: "ellipsis",
46583
- side: "left"
46584
- });
46585
- for (let i = leftBound; i <= rightBound; i++) result.push({
46586
- kind: "page",
46587
- n: i
46588
- });
46589
- if (rightBound < totalPages - 1) result.push({
46590
- kind: "ellipsis",
46591
- side: "right"
46592
- });
46593
- result.push({
46594
- kind: "page",
46595
- n: totalPages
46596
- });
46597
- return result;
46598
- }
46599
- function Pagination({ currentPage, totalItems, pageSize, onPageChange, itemNoun = "devices" }) {
46600
- if (totalItems <= pageSize) return null;
46601
- const totalPages = Math.ceil(totalItems / pageSize);
46602
- const startIdx = (currentPage - 1) * pageSize + 1;
46603
- const endIdx = Math.min(currentPage * pageSize, totalItems);
46604
- const buttons = computePageButtons(currentPage, totalPages);
46605
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
46606
- className: "flex items-center justify-between gap-4 flex-wrap pt-2 text-[11px] text-foreground-subtle",
46607
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
46608
- "Showing ",
46609
- startIdx,
46610
- "–",
46611
- endIdx,
46612
- " of ",
46613
- totalItems,
46614
- " ",
46615
- itemNoun
46616
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
46617
- className: "inline-flex items-center gap-1",
46618
- children: [
46619
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
46620
- type: "button",
46621
- "aria-label": "Previous page",
46622
- disabled: currentPage === 1,
46623
- onClick: () => onPageChange(currentPage - 1),
46624
- 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"),
46625
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { className: "h-3 w-3" })
46626
- }),
46627
- buttons.map((b, i) => {
46628
- if (b.kind === "ellipsis") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
46629
- className: "px-1 text-foreground-subtle/60 select-none",
46630
- children: "…"
46631
- }, `e-${i}`);
46632
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
46633
- type: "button",
46634
- onClick: () => onPageChange(b.n),
46635
- 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"),
46636
- children: b.n
46637
- }, `p-${b.n}`);
46638
- }),
46639
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
46640
- type: "button",
46641
- "aria-label": "Next page",
46642
- disabled: currentPage === totalPages,
46643
- onClick: () => onPageChange(currentPage + 1),
46644
- 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"),
46645
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { className: "h-3 w-3" })
46646
- })
46647
- ]
46648
- })]
46649
- });
46650
- }
46651
- //#endregion
46652
47148
  //#region src/composites/device-list/multi-select.tsx
46653
47149
  /**
46654
47150
  * Compact multi-select dropdown — a single chip-sized trigger ("Label · N")
@@ -54310,6 +54806,7 @@ exports.usePipelineAnalyticsListRetrainStaging = usePipelineAnalyticsListRetrain
54310
54806
  exports.usePipelineAnalyticsListSummaries = usePipelineAnalyticsListSummaries;
54311
54807
  exports.usePipelineAnalyticsListTrackMedia = usePipelineAnalyticsListTrackMedia;
54312
54808
  exports.usePipelineAnalyticsListTracks = usePipelineAnalyticsListTracks;
54809
+ exports.usePipelineAnalyticsOwnersWithMedia = usePipelineAnalyticsOwnersWithMedia;
54313
54810
  exports.usePipelineAnalyticsPauseForStorageMigration = usePipelineAnalyticsPauseForStorageMigration;
54314
54811
  exports.usePipelineAnalyticsProposeRetrainAnnotations = usePipelineAnalyticsProposeRetrainAnnotations;
54315
54812
  exports.usePipelineAnalyticsPruneEvents = usePipelineAnalyticsPruneEvents;