@camstack/ui-library 1.2.193 → 1.2.195

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
@@ -7213,6 +7213,8 @@ var useVideoclipsListClips = trpc.videoclips.listClips.useQuery;
7213
7213
  var useVideoclipsListSources = trpc.videoclips.listSources.useQuery;
7214
7214
  /** Generated alias around `trpc.videoclips.getClipPlayback.useQuery`. */
7215
7215
  var useVideoclipsGetClipPlayback = trpc.videoclips.getClipPlayback.useQuery;
7216
+ /** Generated alias around `trpc.videoclips.readClipBytes.useQuery`. */
7217
+ var useVideoclipsReadClipBytes = trpc.videoclips.readClipBytes.useQuery;
7216
7218
  /** Generated alias around `trpc.waterHeater.setTargetTemp.useMutation`. */
7217
7219
  var useWaterHeaterSetTargetTemp = trpc.waterHeater.setTargetTemp.useMutation;
7218
7220
  /** Generated alias around `trpc.waterHeater.setOperationMode.useMutation`. */
@@ -17684,38 +17686,6 @@ var cva = (base, config) => (props) => {
17684
17686
  }, []), props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
17685
17687
  };
17686
17688
  //#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
- }
17706
- });
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
17715
- });
17716
- });
17717
- Badge.displayName = "Badge";
17718
- //#endregion
17719
17689
  //#region src/primitives/button.tsx
17720
17690
  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
17691
  variants: {
@@ -17991,473 +17961,6 @@ function axisTicks(fromMs, toMs, count) {
17991
17961
  return ticks;
17992
17962
  }
17993
17963
  //#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
17964
  //#region src/composites/device-list/pagination.tsx
18462
17965
  var NEIGHBORS = 2;
18463
17966
  function computePageButtons(currentPage, totalPages) {
@@ -19045,6 +18548,38 @@ function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
19045
18548
  });
19046
18549
  }
19047
18550
  //#endregion
18551
+ //#region src/primitives/badge.tsx
18552
+ var badgeVariants = cva("inline-flex items-center rounded-full text-xs font-medium px-2 py-0.5", {
18553
+ variants: {
18554
+ variant: {
18555
+ default: "bg-surface-hover text-foreground",
18556
+ success: "bg-success/15 text-success",
18557
+ warning: "bg-warning/15 text-warning",
18558
+ danger: "bg-danger/15 text-danger",
18559
+ info: "bg-info/15 text-info"
18560
+ },
18561
+ styleVariant: {
18562
+ solid: "",
18563
+ outline: "border bg-transparent"
18564
+ }
18565
+ },
18566
+ defaultVariants: {
18567
+ variant: "default",
18568
+ styleVariant: "solid"
18569
+ }
18570
+ });
18571
+ var Badge = (0, react$1.forwardRef)(({ className, variant, style, ...props }, ref) => {
18572
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18573
+ ref,
18574
+ className: cn(badgeVariants({
18575
+ variant,
18576
+ styleVariant: style
18577
+ }), className),
18578
+ ...props
18579
+ });
18580
+ });
18581
+ Badge.displayName = "Badge";
18582
+ //#endregion
19048
18583
  //#region src/lib/format-duration.ts
19049
18584
  /**
19050
18585
  * How long something lasted, the way this product says it.
@@ -19558,24 +19093,6 @@ function describeSource(source) {
19558
19093
  answered: copy.answered
19559
19094
  };
19560
19095
  }
19561
- var MINUTE_MS$1 = 6e4;
19562
- var HOUR_MS$1 = 36e5;
19563
- var DAY_MS$2 = 864e5;
19564
- /**
19565
- * How old a source's catalog is, or null when it did not say.
19566
- *
19567
- * Null is NOT "now". A source that omits `catalogAsOf` was read live and has
19568
- * no age to show; inventing "just now" for a source that never told us would
19569
- * be the same lie as drawing a cached list as current (D549 decision 13).
19570
- */
19571
- function formatCatalogAge(catalogAsOf, now) {
19572
- if (catalogAsOf === void 0) return null;
19573
- const age = Math.max(0, now - catalogAsOf);
19574
- if (age < MINUTE_MS$1) return "under a minute ago";
19575
- if (age < HOUR_MS$1) return `${String(Math.floor(age / MINUTE_MS$1))} min ago`;
19576
- if (age < DAY_MS$2) return `${String(Math.floor(age / HOUR_MS$1))} h ago`;
19577
- return `${String(Math.floor(age / DAY_MS$2))} d ago`;
19578
- }
19579
19096
  //#endregion
19580
19097
  //#region src/composites/clips/clip-view-state.ts
19581
19098
  function resolveClipsView(input) {
@@ -19729,56 +19246,6 @@ function resolveClipsView(input) {
19729
19246
  var CLIP_PAGE_LIMIT = 500;
19730
19247
  /** Tiles per page. The rest are a `Pagination` click away, never dropped. */
19731
19248
  var TILES_PER_PAGE = 60;
19732
- var TONE_BADGE = {
19733
- ok: "success",
19734
- warn: "warning",
19735
- error: "danger"
19736
- };
19737
- var SOURCE_COLUMNS = [
19738
- {
19739
- key: "source",
19740
- header: "Source",
19741
- render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19742
- className: "min-w-0",
19743
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19744
- className: "font-medium text-foreground",
19745
- children: row.notice.label
19746
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19747
- className: "text-[10px] text-foreground-subtle",
19748
- children: row.notice.source
19749
- })]
19750
- })
19751
- },
19752
- {
19753
- key: "state",
19754
- header: "State",
19755
- nowrap: false,
19756
- render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
19757
- variant: TONE_BADGE[row.notice.tone],
19758
- children: row.notice.headline
19759
- })
19760
- },
19761
- {
19762
- key: "reason",
19763
- header: "Reason",
19764
- nowrap: false,
19765
- render: (row) => row.notice.detail === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19766
- className: "text-foreground-subtle",
19767
- children: "—"
19768
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19769
- className: "break-words",
19770
- children: row.notice.detail
19771
- })
19772
- },
19773
- {
19774
- key: "read",
19775
- header: "Catalog read",
19776
- render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19777
- className: "text-foreground-subtle",
19778
- children: row.catalogAge ?? "live"
19779
- })
19780
- }
19781
- ];
19782
19249
  /** The sources that contributed NOTHING to what is on screen, and why. */
19783
19250
  function SilentSources({ silent }) {
19784
19251
  if (silent.length === 0) return null;
@@ -19865,7 +19332,6 @@ function ClipsSection({ deviceId }) {
19865
19332
  ...clips.error ? { clipsError: clips.error.message } : {},
19866
19333
  ...sources.error ? { sourcesError: sources.error.message } : {}
19867
19334
  });
19868
- const now = Date.now();
19869
19335
  const options = (0, react$1.useMemo)(() => {
19870
19336
  const byAddon = /* @__PURE__ */ new Map();
19871
19337
  for (const row of sourceRows) {
@@ -19880,10 +19346,6 @@ function ClipsSection({ deviceId }) {
19880
19346
  label: labels.join(" · ")
19881
19347
  }));
19882
19348
  }, [sourceRows]);
19883
- const tableRows = (0, react$1.useMemo)(() => sourceRows.map((row) => ({
19884
- notice: describeSource(row),
19885
- catalogAge: formatCatalogAge(row.availability.catalogAsOf, now)
19886
- })), [sourceRows]);
19887
19349
  (0, react$1.useEffect)(() => {
19888
19350
  setPage(1);
19889
19351
  }, [band.fromMs, band.toMs]);
@@ -19989,19 +19451,6 @@ function ClipsSection({ deviceId }) {
19989
19451
  onSelectBucket: handleSelectBucket,
19990
19452
  densityLoading: density.status === "pending"
19991
19453
  }),
19992
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
19993
- "aria-label": "Clip sources",
19994
- className: "space-y-1",
19995
- 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, {
19996
- icon: Film,
19997
- title: "No provider answered for this camera",
19998
- description: "Nothing claims to hold clips for it."
19999
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTable, {
20000
- columns: SOURCE_COLUMNS,
20001
- rows: [...tableRows],
20002
- minWidthPx: 520
20003
- })
20004
- }),
20005
19454
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
20006
19455
  "aria-label": "Clips",
20007
19456
  className: "space-y-2",
@@ -27680,6 +27129,53 @@ function TooltipContent({ className, children, ...props }) {
27680
27129
  });
27681
27130
  }
27682
27131
  //#endregion
27132
+ //#region src/hooks/use-is-mobile.ts
27133
+ var MOBILE_QUERY = "(max-width: 767px)";
27134
+ /**
27135
+ * Mid breakpoint — below this width the sidebar collapses to an
27136
+ * icons-only rail to reclaim canvas space. Above it (and outside
27137
+ * mobile) the full sidebar renders. Threshold matches Tailwind `lg`
27138
+ * so sidebar fans out at the same point grids switch to multi-column.
27139
+ */
27140
+ var MID_QUERY = "(min-width: 768px) and (max-width: 1023px)";
27141
+ /**
27142
+ * `matchMedia` is universal in browsers but absent in bare DOM environments.
27143
+ * These hooks are called from shared primitives (`DataTable` picks its narrow
27144
+ * layout with one), so throwing here takes down the whole page that merely
27145
+ * rendered a table. Where the capability is missing, report the desktop
27146
+ * layout — a widescreen rendering is wrong-looking; a crashed page is gone.
27147
+ */
27148
+ function matchQuery(query) {
27149
+ if (typeof window.matchMedia !== "function") return null;
27150
+ return window.matchMedia(query);
27151
+ }
27152
+ function subscribeQuery(query) {
27153
+ return (callback) => {
27154
+ const mql = matchQuery(query);
27155
+ if (mql === null) return () => {};
27156
+ mql.addEventListener("change", callback);
27157
+ return () => mql.removeEventListener("change", callback);
27158
+ };
27159
+ }
27160
+ function getSnapshot(query) {
27161
+ return () => matchQuery(query)?.matches ?? false;
27162
+ }
27163
+ function getServerSnapshot() {
27164
+ return false;
27165
+ }
27166
+ function useIsMobile() {
27167
+ return (0, react$1.useSyncExternalStore)(subscribeQuery(MOBILE_QUERY), getSnapshot(MOBILE_QUERY), getServerSnapshot);
27168
+ }
27169
+ /**
27170
+ * True when the viewport is in the medium (`md`) Tailwind range —
27171
+ * 768px ≤ width < 1024px. Use to drive layouts that should pack denser
27172
+ * than full-desktop but more legibly than mobile (e.g. icons-only
27173
+ * sidebar rail, narrower stat tiles).
27174
+ */
27175
+ function useIsMidWidth() {
27176
+ return (0, react$1.useSyncExternalStore)(subscribeQuery(MID_QUERY), getSnapshot(MID_QUERY), getServerSnapshot);
27177
+ }
27178
+ //#endregion
27683
27179
  //#region src/primitives/bottom-sheet.tsx
27684
27180
  function BottomSheet({ open, onClose, title, children, className }) {
27685
27181
  (0, react$1.useEffect)(() => {
@@ -31114,6 +30610,426 @@ function CopyButton({ value, label, srLabel, className, disabled }) {
31114
30610
  });
31115
30611
  }
31116
30612
  //#endregion
30613
+ //#region src/composites/data-table-layout.ts
30614
+ /**
30615
+ * At this many columns and above, `auto` switches a narrow viewport to cards.
30616
+ * Three columns still fit a phone at a readable size; four do not.
30617
+ */
30618
+ var CARD_MODE_MIN_COLUMNS = 4;
30619
+ function resolveTableLayout({ mode, columnCount, isNarrow }) {
30620
+ if (!isNarrow) return "table";
30621
+ if (mode === "scroll") return "table";
30622
+ if (mode === "cards") return "cards";
30623
+ if (columnCount === 0) return "table";
30624
+ return columnCount >= 4 ? "cards" : "table";
30625
+ }
30626
+ //#endregion
30627
+ //#region src/lib/responsive.ts
30628
+ /**
30629
+ * Responsive design tokens — three-tier system.
30630
+ *
30631
+ * The admin-ui targets three width tiers; every component should pick
30632
+ * the right token from this file instead of hand-coding breakpoints.
30633
+ * Adopting the tokens uniformly is what makes the app feel consistent
30634
+ * across operator devices (mobile / tablet / desktop) and what unlocks
30635
+ * targeted tuning (e.g. shrink every label by 1px in one place).
30636
+ *
30637
+ * Tier definitions (Tailwind breakpoints):
30638
+ *
30639
+ * • **L1 — Compact** (`< 640px`, default = mobile portrait)
30640
+ * Single column, tight padding, smallest text, icon-only buttons
30641
+ * where labels can be inferred. The operator can still see every
30642
+ * stat / action — no horizontal scroll, no clipped text. The user
30643
+ * directive: "molta informazione in poco spazio quando la width è
30644
+ * bassa."
30645
+ *
30646
+ * • **L2 — Standard** (`sm` — `640–1023px`, tablet + small laptop)
30647
+ * 2-column grids, side panels become visible, button labels return.
30648
+ * Targets ~768px–1024px viewports — the most common operator sizes.
30649
+ *
30650
+ * • **L3 — Spacious** (`lg+` — `≥ 1024px`, desktop)
30651
+ * 4-column grids, generous padding, full-width header text,
30652
+ * side-by-side composite layouts. The operator has elbow room.
30653
+ *
30654
+ * Conventions:
30655
+ * - **Always start from L1** — the bare class is always the compact
30656
+ * variant, then `sm:` adds room at 640px, `lg:` at 1024px. No
30657
+ * desktop-first breakpoints; no max-width queries.
30658
+ * - **Token strings are static** — kept as `as const` so callers can
30659
+ * compose them via `cn()` / template literals without running a
30660
+ * class generator at runtime.
30661
+ * - **Pair the token with a comment** — the comment in the file
30662
+ * names every breakpoint it crosses, so a reader can predict the
30663
+ * layout without going to Tailwind docs.
30664
+ */
30665
+ /** Container padding for a card-style section (header + body). */
30666
+ var SECTION_CARD = "rounded-lg border border-border bg-surface overflow-hidden";
30667
+ /** Header strip inside a card (between border-b lines). */
30668
+ var SECTION_HEADER = "border-b border-border px-3 sm:px-4 py-1.5 sm:py-2";
30669
+ /** Body padding for a card-style section. */
30670
+ var SECTION_BODY = "p-2 sm:p-3 lg:p-4";
30671
+ /** Gap between cards in a vertical stack. */
30672
+ var STACK_GAP = "space-y-3 sm:space-y-4";
30673
+ /** Gap between cards in a grid (4 cells / row at lg). */
30674
+ var GRID_GAP = "gap-2 sm:gap-3";
30675
+ /** Row padding inside a divided list (e.g. InfoRow). */
30676
+ var LIST_ROW = "px-3 sm:px-4 py-1.5 sm:py-2";
30677
+ /** 1-up at L1, 2-up at L2, 4-up at L3 — quick-stat row. */
30678
+ var GRID_QUICK_STATS = "grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4";
30679
+ /** 1-up at L1, 2-up at L2+ — paired panels (motion + detection rules). */
30680
+ var GRID_PAIRED = "grid grid-cols-1 lg:grid-cols-2";
30681
+ /** Side-by-side layout that stacks below md (canvas + side panel). */
30682
+ var SPLIT_PANEL_OUTER = "flex flex-col-reverse md:flex-row";
30683
+ /** Sidebar narrow lane in a SPLIT_PANEL_OUTER layout. */
30684
+ 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";
30685
+ /**
30686
+ * The tier boundary at which a settings label/value row stops stacking.
30687
+ *
30688
+ * Below it (L1 — compact) the row is ONE column: label on its own line, value
30689
+ * on the next at full width. Above it the row is label-left / value-right as
30690
+ * before. The two-column form needs `SETTING_ROW_LABEL`'s lane plus a readable
30691
+ * value beside it; a phone has neither, and the failure mode is not "cramped"
30692
+ * but "the value is the part that gets cut" — the one thing the operator
30693
+ * opened the page to read.
30694
+ */
30695
+ var SETTING_ROW_STACK_BREAKPOINT = "sm";
30696
+ /** Outer row: single column at L1, label/value columns from `sm` up. */
30697
+ var SETTING_ROW = "flex min-w-0 flex-col gap-0.5 py-1.5 sm:flex-row sm:items-center";
30698
+ /** Label lane. Full width (and free to wrap) at L1; a fixed lane from `sm` up. */
30699
+ var SETTING_ROW_LABEL = "min-w-0 text-[11px] leading-tight text-foreground-subtle sm:w-32 sm:shrink-0 sm:pr-2";
30700
+ /**
30701
+ * Value lane — the value text plus its trailing affordances (copy / reveal).
30702
+ * `w-full` at L1 so the value owns the whole row once the label is above it;
30703
+ * `sm:flex-1` makes it share the row again from `sm` up.
30704
+ */
30705
+ var SETTING_ROW_VALUE = "flex w-full min-w-0 items-center gap-1 sm:flex-1";
30706
+ /**
30707
+ * The value text itself. `break-all` lets an opaque token (a key, a URL) wrap
30708
+ * at L1 instead of being cut mid-word; `sm:truncate` restores the single-line
30709
+ * desktop form, where the row is wide enough for truncation to be a choice
30710
+ * rather than data loss.
30711
+ */
30712
+ var SETTING_ROW_VALUE_TEXT = "min-w-0 break-all text-xs text-foreground sm:truncate";
30713
+ /** Section header label (uppercase tracking-wider). */
30714
+ var TEXT_SECTION_LABEL = "text-[10px] sm:text-[11px] font-semibold text-foreground uppercase tracking-wider";
30715
+ /** Field label inside a row. */
30716
+ var TEXT_FIELD_LABEL = "text-[11px] sm:text-xs text-foreground";
30717
+ /** Sub-label / hint text. */
30718
+ var TEXT_HINT = "text-[10px] text-foreground-subtle";
30719
+ /** Primary value (a metric, badge, …). */
30720
+ var TEXT_VALUE = "text-[11px] sm:text-xs font-medium text-foreground";
30721
+ /** Big metric value (StatCard). */
30722
+ var TEXT_METRIC = "text-base sm:text-lg font-semibold leading-tight text-foreground";
30723
+ /** Default compact button. */
30724
+ 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";
30725
+ /** Primary compact button. */
30726
+ 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";
30727
+ /** Danger compact button. */
30728
+ 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";
30729
+ /** Warning compact button (e.g. cancel-drawing). */
30730
+ 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";
30731
+ /** Compact text/select/number input. */
30732
+ 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";
30733
+ /** Chip-style toggle (zone / class selectors in ZoneRulesEditor). */
30734
+ var CHIP_BASE = "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors";
30735
+ var CHIP_ACTIVE = "border-primary/50 bg-primary/15 text-primary";
30736
+ var CHIP_INACTIVE = "border-border bg-surface text-foreground-subtle hover:bg-surface-hover";
30737
+ //#endregion
30738
+ //#region src/composites/setting-row.tsx
30739
+ function SettingRow({ label, children, actions, elements = "plain", className, valueClassName }) {
30740
+ const LabelTag = elements === "description" ? "dt" : "span";
30741
+ const ValueTag = elements === "description" ? "dd" : "div";
30742
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30743
+ "data-setting-row": "",
30744
+ className: cn(SETTING_ROW, className),
30745
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelTag, {
30746
+ "data-setting-row-label": "",
30747
+ className: SETTING_ROW_LABEL,
30748
+ children: label
30749
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ValueTag, {
30750
+ "data-setting-row-value": "",
30751
+ className: SETTING_ROW_VALUE,
30752
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30753
+ "data-setting-row-value-text": "",
30754
+ className: cn(SETTING_ROW_VALUE_TEXT, valueClassName),
30755
+ children
30756
+ }), actions !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30757
+ className: "flex shrink-0 items-center gap-0.5",
30758
+ children: actions
30759
+ })]
30760
+ })]
30761
+ });
30762
+ }
30763
+ //#endregion
30764
+ //#region src/composites/sort-header-button.tsx
30765
+ function SortHeaderButton({ label, active, dir, onSort }) {
30766
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
30767
+ type: "button",
30768
+ onClick: onSort,
30769
+ className: "group/sort inline-flex items-center gap-0.5 hover:text-foreground transition-colors",
30770
+ 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") })]
30771
+ });
30772
+ }
30773
+ //#endregion
30774
+ //#region src/composites/table-sort.ts
30775
+ /**
30776
+ * The toggle rule, and the only copy of it.
30777
+ *
30778
+ * none → asc → desc → none, and a click on a DIFFERENT column starts that
30779
+ * column fresh at `asc`. `sameColumn` says whether the clicked column is the
30780
+ * one currently sorted; `currentDir` is its direction (`null` when nothing is
30781
+ * sorted). Returns the next direction, or `null` for "unsorted".
30782
+ *
30783
+ * Split out from {@link nextTableSort} so the device list — whose sort state
30784
+ * keys columns by `columnId`, not `columnKey` — can reuse the rule without a
30785
+ * cast and without a second copy of it.
30786
+ */
30787
+ function nextSortDirection(sameColumn, currentDir) {
30788
+ if (!sameColumn || currentDir === null) return "asc";
30789
+ return currentDir === "asc" ? "desc" : null;
30790
+ }
30791
+ /**
30792
+ * Toggle a column's sort on header click: none → asc → desc → none.
30793
+ * Clicking a DIFFERENT column starts it fresh at `asc`.
30794
+ */
30795
+ function nextTableSort(current, columnKey) {
30796
+ const dir = nextSortDirection(current !== null && current.columnKey === columnKey, current === null ? null : current.dir);
30797
+ return dir === null ? null : {
30798
+ columnKey,
30799
+ dir
30800
+ };
30801
+ }
30802
+ /**
30803
+ * Ascending comparison of two cell values.
30804
+ *
30805
+ * Numbers compare numerically and strings compare with `localeCompare` — a
30806
+ * numeric column must never be ordered as text ("100" before "9"), and a text
30807
+ * column must respect the locale's collation. A mixed column (one row a
30808
+ * number, another a string) falls back to string comparison of both, which is
30809
+ * the only total order available; it is not a shape any caller should build on
30810
+ * purpose.
30811
+ *
30812
+ * `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
30813
+ * because its placement is direction-independent and a comparator that is
30814
+ * multiplied by the direction factor cannot express that.
30815
+ */
30816
+ function compareValues(a, b) {
30817
+ if (typeof a === "number" && typeof b === "number") return a - b;
30818
+ return String(a).localeCompare(String(b));
30819
+ }
30820
+ /**
30821
+ * Return a NEW array of `rows` ordered by `sort`.
30822
+ *
30823
+ * `valueOf` is the column's `sortValue` — it is called once per row per
30824
+ * comparison, so it must be cheap (read a field, do not format a string).
30825
+ * `sort === null` ⇒ a fresh shallow copy in the incoming order.
30826
+ */
30827
+ function sortRowsByColumn(rows, sort, valueOf) {
30828
+ if (sort === null) return [...rows];
30829
+ const factor = sort.dir === "asc" ? 1 : -1;
30830
+ return rows.toSorted((a, b) => {
30831
+ const av = valueOf(a);
30832
+ const bv = valueOf(b);
30833
+ if (av === null || bv === null) {
30834
+ if (av === bv) return 0;
30835
+ return av === null ? 1 : -1;
30836
+ }
30837
+ return factor * compareValues(av, bv);
30838
+ });
30839
+ }
30840
+ /** Derive the `<th>` `aria-sort` for `columnKey` from the table's sort state. */
30841
+ function ariaSortForColumn(sort, columnKey) {
30842
+ if (sort === null || sort.columnKey !== columnKey) return "none";
30843
+ return sort.dir === "asc" ? "ascending" : "descending";
30844
+ }
30845
+ //#endregion
30846
+ //#region src/composites/data-table.tsx
30847
+ /**
30848
+ * DataTable — shared responsive table for admin pages.
30849
+ *
30850
+ * Recipes baked in:
30851
+ * - `overflow-x-auto` wrapper so narrow viewports don't clip the
30852
+ * right edge (the rounded border survives the scroll).
30853
+ * - `min-w-[Npx]` on the inner table so columns don't squash below
30854
+ * readable width before the scroll kicks in.
30855
+ * - `whitespace-nowrap` on headers — column titles never wrap to
30856
+ * two lines (`AUTO-KILL` was the regression that triggered this).
30857
+ * - Themed `bg-surface` + `border-border` so contrast works in both
30858
+ * light and dark themes.
30859
+ *
30860
+ * Column model: caller supplies `columns: DataColumn<T>[]` and
30861
+ * `rows: T[]`. Each column declares `header`, optional `align`,
30862
+ * optional `nowrap`, and a `render(row) => ReactNode` that produces
30863
+ * the cell content.
30864
+ *
30865
+ * <DataTable
30866
+ * columns={[
30867
+ * { key: 'username', header: 'Username', render: (u) => u.username },
30868
+ * { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
30869
+ * { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
30870
+ * ]}
30871
+ * rows={users}
30872
+ * minWidthPx={560}
30873
+ * />
30874
+ *
30875
+ * If finer header markup is needed, pass `headerRender` instead of
30876
+ * `header` (e.g. for a sortable click affordance).
30877
+ *
30878
+ * ── Narrow viewports ──────────────────────────────────────────────────────
30879
+ * The table stays a table. The wrapper is `overflow-x-auto`, so a phone
30880
+ * scrolls the columns sideways INSIDE the table — the page body never moves
30881
+ * sideways, and the cells stay one line (`whitespace-nowrap`). That is the
30882
+ * default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
30883
+ * `auto` still applies the old column-count rule for a surface that wants it.
30884
+ * See `resolveTableLayout`.
30885
+ *
30886
+ * ── Scrolling the BODY, not the page ──────────────────────────────────────
30887
+ * `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
30888
+ * with the column header pinned. It is ONE prop, not two, because "this list
30889
+ * scrolls internally" and "its header stays visible" are the same decision: a
30890
+ * capped table whose header scrolls away is a list of unlabelled cells.
30891
+ *
30892
+ * Use it wherever the row count is open-ended (rules, targets, delivery
30893
+ * history) so the page keeps its own header, filters and actions in view while
30894
+ * the operator moves through the rows.
30895
+ */
30896
+ /**
30897
+ * The default cap for an internally-scrolling table.
30898
+ *
30899
+ * `60vh` leaves the page header, its filters and its actions on screen at
30900
+ * every viewport while still showing ~10 rows — and it is a fraction, so it
30901
+ * does not need a breakpoint of its own.
30902
+ */
30903
+ var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
30904
+ var ALIGN_CLASS = {
30905
+ left: "text-left",
30906
+ right: "text-right",
30907
+ center: "text-center"
30908
+ };
30909
+ function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "scroll", maxBodyHeight, defaultSort }) {
30910
+ const isNarrow = useIsMobile();
30911
+ const [sort, setSort] = (0, react$1.useState)(defaultSort ?? null);
30912
+ const layout = resolveTableLayout({
30913
+ mode: mobileMode,
30914
+ columnCount: columns.length,
30915
+ isNarrow
30916
+ });
30917
+ const scrolls = maxBodyHeight !== void 0;
30918
+ const sortedColumn = (0, react$1.useMemo)(() => sort === null ? void 0 : columns.find((col) => col.key === sort.columnKey && col.sortValue !== void 0), [columns, sort]);
30919
+ const sortValue = sortedColumn?.sortValue;
30920
+ const displayRows = (0, react$1.useMemo)(() => sortValue === void 0 ? rows : sortRowsByColumn(rows, sort, sortValue), [
30921
+ rows,
30922
+ sort,
30923
+ sortValue
30924
+ ]);
30925
+ const activeSort = sortedColumn === void 0 ? null : sort;
30926
+ if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30927
+ className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
30928
+ children: emptyMessage
30929
+ });
30930
+ if (rows.length === 0) return null;
30931
+ if (layout === "cards") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30932
+ className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
30933
+ children: displayRows.map((row, rowIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTableCard, {
30934
+ row,
30935
+ rowIndex,
30936
+ columns,
30937
+ bordered,
30938
+ onRowClick,
30939
+ rowClassName
30940
+ }, rowKey ? rowKey(row, rowIndex) : rowIndex))
30941
+ });
30942
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30943
+ className: [
30944
+ "rounded-lg",
30945
+ bordered ? "border border-border" : "",
30946
+ "bg-surface overflow-x-auto",
30947
+ scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
30948
+ className ?? ""
30949
+ ].filter(Boolean).join(" "),
30950
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
30951
+ className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
30952
+ style: { minWidth: `${minWidthPx}px` },
30953
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", {
30954
+ className: "bg-surface",
30955
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: columns.map((col) => {
30956
+ const align = col.align ?? "left";
30957
+ const nowrap = col.nowrap ?? true;
30958
+ const sortable = col.sortValue !== void 0;
30959
+ const isSorted = activeSort !== null && activeSort.columnKey === col.key;
30960
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
30961
+ "aria-sort": sortable ? ariaSortForColumn(activeSort, col.key) : void 0,
30962
+ title: sortable ? void 0 : col.notSortableReason,
30963
+ className: [
30964
+ "px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
30965
+ scrolls ? "sticky top-0 z-10" : "",
30966
+ ALIGN_CLASS[align],
30967
+ nowrap ? "whitespace-nowrap" : "",
30968
+ col.width ?? ""
30969
+ ].filter(Boolean).join(" "),
30970
+ children: sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SortHeaderButton, {
30971
+ label: col.headerRender ? col.headerRender() : col.header,
30972
+ active: isSorted,
30973
+ dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
30974
+ onSort: () => setSort((prev) => nextTableSort(prev, col.key))
30975
+ }) : col.headerRender ? col.headerRender() : col.header
30976
+ }, col.key);
30977
+ }) })
30978
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: displayRows.map((row, rowIndex) => {
30979
+ const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
30980
+ const extra = rowClassName?.(row, rowIndex) ?? "";
30981
+ const interactive = onRowClick !== void 0;
30982
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", {
30983
+ onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
30984
+ className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
30985
+ children: columns.map((col) => {
30986
+ const align = col.align ?? "left";
30987
+ const nowrap = col.nowrap ?? true;
30988
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
30989
+ className: [
30990
+ "px-3 py-2 text-foreground border-b border-border",
30991
+ ALIGN_CLASS[align],
30992
+ nowrap ? "whitespace-nowrap" : ""
30993
+ ].filter(Boolean).join(" "),
30994
+ children: col.render(row, rowIndex)
30995
+ }, col.key);
30996
+ })
30997
+ }, key);
30998
+ }) })]
30999
+ })
31000
+ });
31001
+ }
31002
+ /**
31003
+ * One row as a card. Columns that carry a plain-text `header` become labelled
31004
+ * `SettingRow`s — the label is the column title and the value gets the full
31005
+ * card width. Columns with no text header (an actions column, a
31006
+ * `headerRender`-only column) have no label to show, so they render as a
31007
+ * full-width strip at the foot of the card.
31008
+ */
31009
+ function DataTableCard({ row, rowIndex, columns, bordered, onRowClick, rowClassName }) {
31010
+ const labelled = columns.filter((col) => col.header !== void 0 && col.header !== "");
31011
+ const unlabelled = columns.filter((col) => col.header === void 0 || col.header === "");
31012
+ const interactive = onRowClick !== void 0;
31013
+ const extra = rowClassName?.(row, rowIndex) ?? "";
31014
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31015
+ "data-data-table-card": "",
31016
+ onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
31017
+ className: [
31018
+ "rounded-lg bg-surface px-3 py-1.5 divide-y divide-border-subtle",
31019
+ bordered ? "border border-border" : "",
31020
+ interactive ? "cursor-pointer hover:bg-primary/5" : "",
31021
+ extra
31022
+ ].filter(Boolean).join(" "),
31023
+ children: [labelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
31024
+ label: col.header,
31025
+ children: col.render(row, rowIndex)
31026
+ }, col.key)), unlabelled.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31027
+ className: "flex flex-wrap items-center justify-end gap-2 py-1.5",
31028
+ children: unlabelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: col.render(row, rowIndex) }, col.key))
31029
+ })]
31030
+ });
31031
+ }
31032
+ //#endregion
31117
31033
  //#region src/composites/detection-colors.ts
31118
31034
  /**
31119
31035
  * Detection class colors — a thin adapter over the SINGLE event-kind
@@ -55260,6 +55176,7 @@ exports.useVibrationGetStatus = useVibrationGetStatus;
55260
55176
  exports.useVideoclipsGetClipPlayback = useVideoclipsGetClipPlayback;
55261
55177
  exports.useVideoclipsListClips = useVideoclipsListClips;
55262
55178
  exports.useVideoclipsListSources = useVideoclipsListSources;
55179
+ exports.useVideoclipsReadClipBytes = useVideoclipsReadClipBytes;
55263
55180
  exports.useVodPlayback = useVodPlayback;
55264
55181
  exports.useWaterHeaterGetStatus = useWaterHeaterGetStatus;
55265
55182
  exports.useWaterHeaterSetAway = useWaterHeaterSetAway;