@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/generated/system-hooks.d.ts +2 -0
- package/dist/index.cjs +502 -585
- package/dist/index.js +502 -586
- package/dist/primitives/badge.d.ts +1 -1
- package/dist/primitives/button.d.ts +1 -1
- package/dist/primitives/icon-button.d.ts +1 -1
- package/package.json +1 -1
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;
|