@protobase/ui 0.21.8 → 0.21.9

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.
Files changed (62) hide show
  1. package/dist/app/app.js +1 -0
  2. package/dist/app/charts/chart-view.js +3 -1
  3. package/dist/app/displays/calendar-controls.js +2 -1
  4. package/dist/app/displays/calendar-display.js +2 -1
  5. package/dist/app/displays/calendar-event.js +2 -1
  6. package/dist/app/displays/calendar-range.js +18 -14
  7. package/dist/app/export/cells.js +6 -4
  8. package/dist/app/field-editor.js +13 -1
  9. package/dist/app/import/convert.js +2 -12
  10. package/dist/app/import/locale-formats.js +2 -1
  11. package/dist/app/incoming-card.js +35 -0
  12. package/dist/app/incoming-relations.js +36 -0
  13. package/dist/app/list-chart.js +3 -1
  14. package/dist/app/pages/field-value.js +9 -1
  15. package/dist/app/pages/record-actions.js +34 -16
  16. package/dist/app/record-page.js +26 -18
  17. package/dist/app/runtime/runtime-status.js +5 -4
  18. package/dist/app/search/navigation-hits.js +45 -0
  19. package/dist/app/search/use-global-search.js +13 -4
  20. package/dist/app/use-incoming-counts.js +38 -0
  21. package/dist/app/use-related-records.js +3 -10
  22. package/dist/app-shell/app-shell.js +9 -2
  23. package/dist/app-shell/index.d.ts +1 -0
  24. package/dist/app-shell/logo.d.ts +5 -1
  25. package/dist/app-shell/logo.js +10 -3
  26. package/dist/app-shell/sidebar.js +3 -2
  27. package/dist/app-shell/top-bar-slot.js +11 -0
  28. package/dist/app-shell/top-bar.d.ts +3 -0
  29. package/dist/app-shell/top-bar.js +27 -19
  30. package/dist/data-grid/cells.js +8 -1
  31. package/dist/data-grid/grid-header.js +17 -16
  32. package/dist/data-grid/header-menu.js +16 -6
  33. package/dist/data-grid/row-menu.js +2 -39
  34. package/dist/format/date.js +29 -9
  35. package/dist/format/number-text.js +16 -0
  36. package/dist/format/zone.js +62 -0
  37. package/dist/live/columns-from-model.js +2 -0
  38. package/dist/live/contact-link.js +25 -0
  39. package/dist/live/contact-links.js +42 -0
  40. package/dist/live/display-value.js +14 -4
  41. package/dist/live/field-values.js +20 -9
  42. package/dist/live/filter-string.js +30 -30
  43. package/dist/package.js +1 -1
  44. package/dist/primitives/popover-menu.js +56 -0
  45. package/dist/record-view/action-menu.js +59 -0
  46. package/dist/record-view/record-header.js +2 -6
  47. package/dist/record-view/record-sidebar.js +2 -30
  48. package/dist/shell/assets/app-B_L-OYQJ.js +69 -0
  49. package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
  50. package/dist/shell/assets/{index-xN6_oZU2.js → index-CRNWPM01.js} +1 -1
  51. package/dist/shell/assets/index-CzqIlZPP.css +2 -0
  52. package/dist/shell/assets/{maplibre-Ba9RapjY.js → maplibre-DZlOrjmh.js} +1 -1
  53. package/dist/shell/assets/{markdown-content-BUxrbovf.js → markdown-content-DT_18T89.js} +1 -1
  54. package/dist/shell/assets/{record-map-14zotZ2s.js → record-map-Bmns3gJf.js} +1 -1
  55. package/dist/shell/assets/{records-map-wi-0pXqF.js → records-map-Ci3qFCDs.js} +1 -1
  56. package/dist/shell/assets/{shared-protobase-ui-BVwhKZk9.js → shared-protobase-ui-DPgVYrS4.js} +1 -1
  57. package/dist/shell/index.html +4 -4
  58. package/dist/shell/protobase.shell.json +1 -1
  59. package/package.json +4 -4
  60. package/dist/shell/assets/app-C01lazBK.js +0 -69
  61. package/dist/shell/assets/export-worker-BnOpS_4Q.js +0 -2
  62. package/dist/shell/assets/index-CBGigFxb.css +0 -2
package/dist/app/app.js CHANGED
@@ -193,6 +193,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
193
193
  onSignOut: user && (() => void signOut()),
194
194
  signIn: user ? void 0 : signIn.link(),
195
195
  workspace: current?.organization?.name ?? workspace,
196
+ appName: meta.appName,
196
197
  logo: ProjectLogo,
197
198
  navGroups: groups,
198
199
  bottomBar: bottomBarFromMeta(meta, basePath, place),
@@ -1,4 +1,5 @@
1
1
  import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { viewerZone } from "../../format/zone.js";
2
3
  import { labelFieldOf } from "../../live/model-helpers.js";
3
4
  import { useAdminMeta } from "../meta-gate.js";
4
5
  import { useAggregate } from "../../data/use-aggregates.js";
@@ -30,7 +31,8 @@ const ChartView = ({ model, view, groupBy, type = "bar", sum, average, filter =
30
31
  const label = target && target.primaryKey.length === 1 ? labelFieldOf(target, views[target.name]) : void 0;
31
32
  const params = dated ? {
32
33
  granularity,
33
- range
34
+ range,
35
+ timeZone: viewerZone()
34
36
  } : {
35
37
  limit,
36
38
  ...label && { label }
@@ -1,4 +1,5 @@
1
1
  import { segmentedClass } from "../../primitives/segmented.js";
2
+ import { dayIn, viewerZone } from "../../format/zone.js";
2
3
  import { formatDayRange, formatMonth } from "../../format/date.js";
3
4
  import { buttonClass } from "../../primitives/button.js";
4
5
  import { AppLink } from "../use-app-address.js";
@@ -31,7 +32,7 @@ const CalendarControls = ({ view, now, linkTo, onView }) => {
31
32
  onClick: () => onView?.(to),
32
33
  children
33
34
  });
34
- const holdsToday = view.scale === "month" && new Date(now).toISOString().startsWith(view.day.slice(0, 7));
35
+ const holdsToday = view.scale === "month" && dayIn(now, viewerZone()).startsWith(view.day.slice(0, 7));
35
36
  const scaled = (to) => to === "week" && holdsToday ? todayView("week", now) : calendarView({
36
37
  scale: to,
37
38
  day: view.day
@@ -1,5 +1,6 @@
1
1
  import { cn } from "../../lib/cn.js";
2
2
  import { Skeleton } from "../../primitives/skeleton.js";
3
+ import { dayIn, viewerZone } from "../../format/zone.js";
3
4
  import { formatDate, formatWeekday } from "../../format/date.js";
4
5
  import { ErrorBanner } from "../error-banner.js";
5
6
  import { usePermissions } from "../meta-gate.js";
@@ -51,7 +52,7 @@ const CalendarDisplay = ({ model, view, display, fields, filter, at, linkTo, now
51
52
  },
52
53
  coordinateGetter: dayCoordinates
53
54
  }));
54
- const today = new Date(now).toISOString().slice(0, 10);
55
+ const today = dayIn(now, viewerZone());
55
56
  const week = shown.scale === "week";
56
57
  const dayText = (id) => formatDate(Date.parse(`${String(id).slice(dayId("").length)}T00:00:00Z`));
57
58
  const title = (picked) => eventTitle(model, view, display, picked.row);
@@ -1,4 +1,5 @@
1
1
  import { cn } from "../../lib/cn.js";
2
+ import { dayIn, viewerZone } from "../../format/zone.js";
2
3
  import { formatTime } from "../../format/date.js";
3
4
  import { recordTitleField } from "../../live/model-helpers.js";
4
5
  import { displayValue } from "../../live/display-value.js";
@@ -19,7 +20,7 @@ const startTime = (model, display, row, day) => {
19
20
  const value = row.record[display.start];
20
21
  if (model.fields[display.start]?.type !== "timestamp" || typeof value !== "string") return void 0;
21
22
  const ms = Date.parse(value);
22
- return new Date(ms).toISOString().startsWith(day) ? formatTime(ms) : void 0;
23
+ return dayIn(ms, viewerZone()) === day ? formatTime(ms) : void 0;
23
24
  };
24
25
  /** An event as a one-line link to its record, its time first for a timestamp, styled as a small record card. */
25
26
  const EventChip = ({ model, view, display, row, day }) => {
@@ -1,3 +1,4 @@
1
+ import { dayIn, instantAt, startOfDayIn, viewerZone, zoneOffset } from "../../format/zone.js";
1
2
  import { printFilter, where } from "@protobase/schema";
2
3
  //#region src/app/displays/calendar-range.ts
3
4
  const dayMs = 864e5;
@@ -15,14 +16,14 @@ const normalised = ({ scale, day }) => ({
15
16
  day: scale === "month" ? monthOf(day) : monday(day)
16
17
  });
17
18
  /** The month or week a calendar shows: the address's, starting on the month's 1st or the week's Monday, else the month of `now`. */
18
- const calendarView = (at, now) => normalised(at ?? {
19
+ const calendarView = (at, now, zone = viewerZone()) => normalised(at ?? {
19
20
  scale: "month",
20
- day: dayOf(now)
21
+ day: dayIn(now, zone)
21
22
  });
22
23
  /** The month or week of `scale` that holds `now`. */
23
- const todayView = (scale, now) => normalised({
24
+ const todayView = (scale, now, zone = viewerZone()) => normalised({
24
25
  scale,
25
- day: dayOf(now)
26
+ day: dayIn(now, zone)
26
27
  });
27
28
  /** The month or week before (-1) or after (1) `view`. */
28
29
  const stepView = (view, step) => view.scale === "month" ? {
@@ -53,11 +54,12 @@ const isTimestamp = (display, model) => model.fields[display.start]?.type === "t
53
54
  * it and ending in or after its first day. A timestamp's `end` is the instant it is over, so one ending at midnight is
54
55
  * not on the day it starts. Each branch reads one indexed field.
55
56
  */
56
- const rangeFilter = (display, model, range) => {
57
+ const rangeFilter = (display, model, range, zone = viewerZone()) => {
57
58
  const timestamp = isTimestamp(display, model);
59
+ const instant = (day) => new Date(startOfDayIn(day, zone)).toISOString().replace(".000Z", "Z");
58
60
  const bound = (day) => timestamp ? {
59
61
  kind: "timestamp",
60
- value: `${day}T00:00:00Z`,
62
+ value: instant(day),
61
63
  span: {
62
64
  start: 0,
63
65
  end: 0
@@ -86,33 +88,35 @@ const instantOf = (value) => {
86
88
  * The days of `days` a record is on: from its start's day through its end's, both included, where a timestamp `end`
87
89
  * at midnight is over before that day starts; its start day alone without an end or with one before its start.
88
90
  */
89
- const eventDays = (record, display, model, days) => {
91
+ const eventDays = (record, display, model, days, zone = viewerZone()) => {
90
92
  const start = instantOf(record[display.start]);
91
93
  if (start === void 0) return [];
92
- const first = dayOf(start);
94
+ const timestamp = isTimestamp(display, model);
95
+ const day = (ms) => timestamp ? dayIn(ms, zone) : dayOf(ms);
96
+ const first = day(start);
93
97
  const end = display.end === void 0 ? void 0 : instantOf(record[display.end]);
94
- const ending = end === void 0 ? first : dayOf(isTimestamp(display, model) ? end - 1 : end);
98
+ const ending = end === void 0 ? first : day(timestamp ? end - 1 : end);
95
99
  const last = ending < first ? first : ending;
96
100
  return days.filter((day) => day >= first && day <= last);
97
101
  };
98
102
  /** The rows on each day of `days`, each day's in start order. */
99
- const byDay = (rows, display, model, days) => {
103
+ const byDay = (rows, display, model, days, zone = viewerZone()) => {
100
104
  const placed = new Map(days.map((day) => [day, []]));
101
105
  const ordered = rows.toSorted((a, b) => (instantOf(a.record[display.start]) ?? 0) - (instantOf(b.record[display.start]) ?? 0));
102
- for (const row of ordered) for (const day of eventDays(row.record, display, model, days)) placed.get(day).push(row);
106
+ for (const row of ordered) for (const day of eventDays(row.record, display, model, days, zone)) placed.get(day).push(row);
103
107
  return placed;
104
108
  };
105
109
  /**
106
110
  * The update that moves a record shown on `from` to `to`: its `start`, and its `end` when it has one, moved by the same
107
- * number of days, a timestamp keeping its time of day.
111
+ * number of days, a timestamp keeping its time of day on the clock of `zone`.
108
112
  */
109
- const movedPatch = (record, display, model, from, to) => {
113
+ const movedPatch = (record, display, model, from, to, zone = viewerZone()) => {
110
114
  const days = (startOf(to) - startOf(from)) / dayMs;
111
115
  const timestamp = isTimestamp(display, model);
112
116
  const moved = (value) => {
113
117
  const ms = instantOf(value);
114
118
  if (ms === void 0) return void 0;
115
- return timestamp ? new Date(ms + days * dayMs).toISOString() : addDays(dayOf(ms), days);
119
+ return timestamp ? new Date(instantAt(ms + zoneOffset(ms, zone) + days * dayMs, zone)).toISOString() : addDays(dayOf(ms), days);
116
120
  };
117
121
  const start = moved(record[display.start]);
118
122
  const end = display.end === void 0 ? void 0 : moved(record[display.end]);
@@ -1,3 +1,4 @@
1
+ import { viewerZone, zoneOffset } from "../../format/zone.js";
1
2
  import { fileShown, isStoredFile } from "../../live/file-values.js";
2
3
  import { displayValue } from "../../live/display-value.js";
3
4
  //#region src/app/export/cells.ts
@@ -57,17 +58,18 @@ const numberCell = (column, value, mode) => {
57
58
  value: number
58
59
  };
59
60
  };
60
- const dateCell = (type, value, mode) => {
61
+ const dateCell = (type, value, mode, zone) => {
61
62
  const ms = Date.parse(String(value));
62
63
  if (Number.isNaN(ms)) return text(String(value));
64
+ const clock = type === "timestamp" && mode === "shown" ? ms + zoneOffset(ms, zone) : ms;
63
65
  return {
64
66
  kind: "number",
65
- value: excelSerial(ms),
67
+ value: excelSerial(clock),
66
68
  format: dateFormats[mode][type]
67
69
  };
68
70
  };
69
71
  /** A value as one typed Excel cell: numbers, dates and booleans as such, labels or stored values as text. */
70
- const xlsxCell = (column, value, labels, mode) => {
72
+ const xlsxCell = (column, value, labels, mode, zone = viewerZone()) => {
71
73
  if (isEmpty(value)) return empty;
72
74
  const { field } = column;
73
75
  switch (field.type) {
@@ -79,7 +81,7 @@ const xlsxCell = (column, value, labels, mode) => {
79
81
  case "bigint":
80
82
  case "decimal": return numberCell(column, value, mode);
81
83
  case "date":
82
- case "timestamp": return dateCell(field.type, value, mode);
84
+ case "timestamp": return dateCell(field.type, value, mode, zone);
83
85
  case "relation":
84
86
  if (mode === "shown") return text(relationLabel(column, value, labels));
85
87
  return typeof value === "number" ? {
@@ -7,7 +7,8 @@ import { fromDraft, isEditable, toFieldDraft } from "../live/field-values.js";
7
7
  import { Switch } from "../primitives/switch.js";
8
8
  import { Textarea } from "../primitives/textarea.js";
9
9
  import { isMarkdown } from "../markdown/markdown-hint.js";
10
- import { displayValue } from "../live/display-value.js";
10
+ import { contactFormat, contactInputProps } from "../live/contact-links.js";
11
+ import { displayValue, valueTitle } from "../live/display-value.js";
11
12
  import { AppLink } from "./use-app-address.js";
12
13
  import { RelationPicker } from "./relation-picker.js";
13
14
  import { SensitiveField } from "./sensitive-field.js";
@@ -17,6 +18,7 @@ import { WidgetPart } from "./field-types/widget-part.js";
17
18
  import { Markdown } from "../markdown/markdown.js";
18
19
  import { MarkdownEditor } from "../markdown/markdown-editor.js";
19
20
  import { numeric, numericInputProps } from "./numeric-input.js";
21
+ import { ContactLink } from "../live/contact-link.js";
20
22
  import { ArrowUpRight } from "lucide-react";
21
23
  import { jsx, jsxs } from "react/jsx-runtime";
22
24
  import { enumLabel } from "@protobase/schema";
@@ -95,7 +97,16 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
95
97
  source: stored,
96
98
  className: "py-1.5"
97
99
  });
100
+ const contact = field.type === "text" ? contactFormat(hints) : void 0;
101
+ if ((!isEditable(field) || locked) && contact && typeof stored === "string" && stored !== "") return /* @__PURE__ */ jsx("div", {
102
+ className: "min-w-0 break-words py-1.5",
103
+ children: /* @__PURE__ */ jsx(ContactLink, {
104
+ format: contact,
105
+ value: stored
106
+ })
107
+ });
98
108
  if (!isEditable(field) || locked) return /* @__PURE__ */ jsx("div", {
109
+ title: valueTitle(field, stored),
99
110
  className: cn("py-1.5 tabular-nums", field.type === "decimal" || field.type === "integer" ? "font-medium" : "text-muted-foreground", multiline && "whitespace-pre-line break-words"),
100
111
  children: displayValue(field, stored, hints)
101
112
  });
@@ -133,6 +144,7 @@ const BuiltInEditor = ({ field, hints, value, stored, relationLabel, relationTo,
133
144
  placeholder,
134
145
  className: cn(numeric.has(field.type) && "text-right tabular-nums"),
135
146
  ...numericInputProps(field.type),
147
+ ...contactInputProps(contact),
136
148
  value: String(value),
137
149
  onChange: (event) => onChange(event.target.value)
138
150
  });
@@ -1,3 +1,4 @@
1
+ import { parseNumberText } from "../../format/number-text.js";
1
2
  import { cellText, isEmptyCell, isSheetDate } from "./sheet-cell.js";
2
3
  import { enumLabel } from "@protobase/schema";
3
4
  //#region src/app/import/convert.ts
@@ -10,17 +11,6 @@ const problem = (text) => ({
10
11
  problem: text
11
12
  });
12
13
  const quoted = (cell) => `“${cellText(cell)}”`;
13
- const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
- /**
15
- * A number written as text, as a plain decimal (`-1234.5`): digits grouped by thousands with the other separator or
16
- * spaces, and the `decimal` separator before the fraction. Undefined when the text is no such number.
17
- */
18
- const parseNumberText = (text, decimal) => {
19
- const group = `[${escapeRegExp(decimal === "." ? "," : ".")}\\s\\u00a0\\u202f']`;
20
- const match = new RegExp(`^([-+]?)(\\d{1,3}(?:${group}\\d{3})+|\\d+)(?:${escapeRegExp(decimal)}(\\d+))?$`).exec(text.trim());
21
- if (match) return `${match[1] === "-" ? "-" : ""}${match[2].replace(/\D/g, "")}${match[3] ? `.${match[3]}` : ""}`;
22
- if (decimal === "." && /^[-+]?\d+(\.\d+)?e[-+]?\d+$/i.test(text.trim())) return String(Number(text.trim()));
23
- };
24
14
  const toNumberText = (cell, options) => {
25
15
  if (typeof cell === "number") return String(cell);
26
16
  if (typeof cell === "string") return parseNumberText(cell, options.decimal);
@@ -148,4 +138,4 @@ const convertCell = (field, hints, cell, options) => {
148
138
  }
149
139
  };
150
140
  //#endregion
151
- export { convertCell, parseNumberText };
141
+ export { convertCell };
@@ -1,10 +1,11 @@
1
+ import { localeDecimal } from "../../format/number-text.js";
1
2
  //#region src/app/import/locale-formats.ts
2
3
  /**
3
4
  * How the browser's locale writes numbers and dates, as the import's starting choice: its decimal separator, and whether
4
5
  * its dates put the day or the month first. The user sees both and can change them.
5
6
  */
6
7
  const localeFormats = (locale) => {
7
- const decimal = new Intl.NumberFormat(locale).formatToParts(1.5).find((part) => part.type === "decimal")?.value === "," ? "," : ".";
8
+ const decimal = localeDecimal(locale);
8
9
  const parts = new Intl.DateTimeFormat(locale).formatToParts(new Date(2026, 3, 3)).map((part) => part.type);
9
10
  return {
10
11
  decimal,
@@ -0,0 +1,35 @@
1
+ import { Skeleton } from "../primitives/skeleton.js";
2
+ import { formatInt } from "../format/number.js";
3
+ import { SidebarCard } from "../record-view/record-sidebar.js";
4
+ import { useAdminMeta } from "./meta-gate.js";
5
+ import { AppLink } from "./use-app-address.js";
6
+ import { incomingAddress, incomingRelations } from "./incoming-relations.js";
7
+ import { useIncomingCounts } from "./use-incoming-counts.js";
8
+ import { ChevronRight } from "lucide-react";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ //#region src/app/incoming-card.tsx
11
+ /** The rail's card of the records pointing at this one: per resource and field, how many, linking to them in their list. */
12
+ const IncomingCard = ({ model, recordKey, layout, lines }) => {
13
+ const { resources, views } = useAdminMeta();
14
+ const counts = useIncomingCounts(incomingRelations(model, resources, views, layout, lines), resources, recordKey);
15
+ if (counts.length === 0) return null;
16
+ return /* @__PURE__ */ jsx(SidebarCard, {
17
+ title: "Related records",
18
+ children: /* @__PURE__ */ jsx("ul", {
19
+ className: "-mx-2",
20
+ children: counts.map(({ relation, count }) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(AppLink, {
21
+ to: incomingAddress(relation, recordKey),
22
+ className: "group flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-muted",
23
+ children: [/* @__PURE__ */ jsx("span", {
24
+ className: "min-w-0 truncate font-medium text-primary-text",
25
+ children: relation.label
26
+ }), /* @__PURE__ */ jsxs("span", {
27
+ className: "flex shrink-0 items-center gap-1 tabular-nums text-muted-foreground",
28
+ children: [count === void 0 ? /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-6" }) : `${formatInt(count.count)}${count.exact ? "" : "+"}`, /* @__PURE__ */ jsx(ChevronRight, { className: "size-4 text-faint-foreground group-hover:text-foreground" })]
29
+ })]
30
+ }) }, `${relation.resource}.${relation.field}`))
31
+ })
32
+ });
33
+ };
34
+ //#endregion
35
+ export { IncomingCard };
@@ -0,0 +1,36 @@
1
+ import { humanize } from "../live/naming.js";
2
+ import { createWhere, printFilter } from "@protobase/schema";
3
+ //#region src/app/incoming-relations.ts
4
+ const where$1 = createWhere();
5
+ /**
6
+ * The relations that point at records of `model`, as the rail lists them by default: each filterable relation of
7
+ * another resource the user can see (a hidden resource or field is not in the meta), to a record of one key column.
8
+ * Left out are a resource's tenant, which every record holds, the lines drawn on the page, and what an `l.related`
9
+ * section of the page already lists. Two fields of one resource are told apart by their labels.
10
+ */
11
+ const incomingRelations = (model, resources, views, layout, lines) => {
12
+ if (model.primaryKey.length !== 1) return [];
13
+ const listed = new Set(layout.flatMap((item) => item.kind === "related" && !item.through ? [`${item.resource}.${item.field}`] : []));
14
+ const found = Object.values(resources).flatMap((source) => Object.values(source.fields).filter((field) => field.type === "relation" && field.relation?.resource === model.name && field.relation.columns.length === 1 && field.filterable).filter((field) => field.name !== source.tenant && source.name !== lines?.name && !listed.has(`${source.name}.${field.name}`)).map((field) => ({
15
+ source,
16
+ field: field.name
17
+ })));
18
+ return found.map(({ source, field }) => {
19
+ const view = views[source.name];
20
+ const plural = view?.names?.plural ?? humanize(source.name);
21
+ const several = found.filter((other) => other.source.name === source.name).length > 1;
22
+ return {
23
+ resource: source.name,
24
+ field,
25
+ label: several ? `${plural} (${view?.fields[field]?.label ?? humanize(field)})` : plural
26
+ };
27
+ });
28
+ };
29
+ /** The list of the records that point at `key` through `relation`, filtered as its list's address reads. */
30
+ const incomingAddress = (relation, key) => ({
31
+ kind: "list",
32
+ resource: relation.resource,
33
+ filter: printFilter(where$1.eq(relation.field, key))
34
+ });
35
+ //#endregion
36
+ export { incomingAddress, incomingRelations };
@@ -1,3 +1,4 @@
1
+ import { viewerZone } from "../format/zone.js";
1
2
  import { useSeries } from "../data/use-aggregates.js";
2
3
  import { ActivityChart } from "../activity-chart/activity-chart.js";
3
4
  import { useState } from "react";
@@ -18,7 +19,8 @@ const ListChart = ({ resource, chart, title, noun, filter }) => {
18
19
  field: chart.field,
19
20
  range,
20
21
  granularity,
21
- filter
22
+ filter,
23
+ timeZone: viewerZone()
22
24
  }).data?.points.map((point) => ({
23
25
  at: Date.parse(`${point.bucket}Z`),
24
26
  count: point.count
@@ -3,13 +3,15 @@ import { keys } from "../../data/query-keys.js";
3
3
  import { useClient } from "../../data/api-provider.js";
4
4
  import { fileShown, shownImage } from "../../live/file-values.js";
5
5
  import { isMarkdown } from "../../markdown/markdown-hint.js";
6
- import { displayValue } from "../../live/display-value.js";
6
+ import { contactFormat } from "../../live/contact-links.js";
7
+ import { displayValue, valueTitle } from "../../live/display-value.js";
7
8
  import { useAdminMeta } from "../meta-gate.js";
8
9
  import { AppLink } from "../use-app-address.js";
9
10
  import { FileImage } from "../../live/file-image.js";
10
11
  import { useShownWidget } from "../field-types/shown-widget.js";
11
12
  import { WidgetPart } from "../field-types/widget-part.js";
12
13
  import { Markdown } from "../../markdown/markdown.js";
14
+ import { ContactLink } from "../../live/contact-link.js";
13
15
  import { fetchLabels } from "../../live/relation-labels.js";
14
16
  import { ValueBadge } from "../value-badge.js";
15
17
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -74,12 +76,18 @@ const BuiltInValue = ({ model, field, value, format, relationLabel }) => {
74
76
  }
75
77
  if (isMarkdown(field, hints)) return /* @__PURE__ */ jsx(Markdown, { source: String(value) });
76
78
  const text = displayValue(field, value, hints);
79
+ const contact = field.type === "text" ? contactFormat(hints) : void 0;
80
+ if (contact) return /* @__PURE__ */ jsx(ContactLink, {
81
+ format: contact,
82
+ value: text
83
+ });
77
84
  if (field.type === "enum" || hints.format === "badge") return /* @__PURE__ */ jsx(ValueBadge, {
78
85
  value,
79
86
  hints,
80
87
  children: text
81
88
  });
82
89
  return /* @__PURE__ */ jsx("span", {
90
+ title: valueTitle(field, value),
83
91
  className: cn(hints.format === "code" && "font-mono text-xs", (field.type === "decimal" || field.type === "integer") && "tabular-nums", hints.multiline && "whitespace-pre-line break-words"),
84
92
  children: text
85
93
  });
@@ -1,4 +1,7 @@
1
+ import { useTopBarSlot } from "../../app-shell/top-bar-slot.js";
2
+ import "../../app-shell/top-bar.js";
1
3
  import { ActionRail } from "../../record-view/action-rail.js";
4
+ import { ActionMenu } from "../../record-view/action-menu.js";
2
5
  import { AppLink, useAppAddress } from "../use-app-address.js";
3
6
  import { ActionPrompt, asksFirst } from "./action-prompt.js";
4
7
  import { useActionRunner } from "./use-run-action.js";
@@ -6,14 +9,17 @@ import { actionIcon } from "../action-icon.js";
6
9
  import { useState } from "react";
7
10
  import { Pencil, Trash2 } from "lucide-react";
8
11
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
12
+ import { createPortal } from "react-dom";
9
13
  //#region src/app/pages/record-actions.tsx
10
14
  /**
11
15
  * The record page's actions in one rail: Edit, a link to the form at `edit`, when the page offers it; the view's named
12
16
  * actions, run like the Action block runs them, a delete among them returning to the list; and the record's own Delete,
13
- * `onDelete`, unless a named action deletes already.
17
+ * `onDelete`, unless a named action deletes already. On a mobile screen they sit in the top bar's menu instead, at hand
18
+ * however long the record is, when the shell has a top bar.
14
19
  */
15
20
  const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
16
21
  const { go } = useAppAddress();
22
+ const slot = useTopBarSlot();
17
23
  const scope = {
18
24
  model,
19
25
  record: stored.record,
@@ -39,8 +45,9 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
39
45
  ...edit ? [{
40
46
  name: "Edit",
41
47
  icon: Pencil,
42
- render: ({ className, children }) => /* @__PURE__ */ jsx(AppLink, {
48
+ render: ({ className, children, role }) => /* @__PURE__ */ jsx(AppLink, {
43
49
  to: edit,
50
+ role,
44
51
  className,
45
52
  children
46
53
  })
@@ -54,20 +61,31 @@ const RecordActions = ({ model, stored, recordKey, edit, onDelete }) => {
54
61
  }] : []
55
62
  ];
56
63
  if (actions.length === 0) return null;
57
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ActionRail, { actions }), asking && /* @__PURE__ */ jsx(ActionPrompt, {
58
- action: asking,
59
- label: asking.label,
60
- danger: asking.run?.kind === "delete",
61
- onCancel: () => setAsking(void 0),
62
- onConfirm: () => {
63
- setAsking(void 0);
64
- runner.run(asking);
65
- },
66
- onSubmit: async (values) => {
67
- await runner.submit(asking, values);
68
- setAsking(void 0);
69
- }
70
- })] });
64
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
65
+ /* @__PURE__ */ jsx("div", {
66
+ className: slot ? "max-md:hidden" : void 0,
67
+ children: /* @__PURE__ */ jsx(ActionRail, { actions })
68
+ }),
69
+ slot && createPortal(/* @__PURE__ */ jsx(ActionMenu, {
70
+ actions,
71
+ label: "Record actions",
72
+ className: "inline-flex size-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted md:hidden"
73
+ }), slot),
74
+ asking && /* @__PURE__ */ jsx(ActionPrompt, {
75
+ action: asking,
76
+ label: asking.label,
77
+ danger: asking.run?.kind === "delete",
78
+ onCancel: () => setAsking(void 0),
79
+ onConfirm: () => {
80
+ setAsking(void 0);
81
+ runner.run(asking);
82
+ },
83
+ onSubmit: async (values) => {
84
+ await runner.submit(asking, values);
85
+ setAsking(void 0);
86
+ }
87
+ })
88
+ ] });
71
89
  };
72
90
  //#endregion
73
91
  export { RecordActions };
@@ -6,12 +6,12 @@ import { Button, buttonClass } from "../primitives/button.js";
6
6
  import { usePauseFocusRefresh } from "../data/focus-refresh.js";
7
7
  import { keys } from "../data/query-keys.js";
8
8
  import { useClient } from "../data/api-provider.js";
9
- import { useNavigationGuard, useRouter } from "./router.js";
9
+ import { useNavigationGuard } from "./router.js";
10
10
  import { useOpenedRecord } from "../data/use-record.js";
11
11
  import { humanize } from "../live/naming.js";
12
12
  import { Field } from "../record-view/field.js";
13
13
  import { Section } from "../record-view/section.js";
14
- import { MetadataCard, RelatedRecords, SidebarCard } from "../record-view/record-sidebar.js";
14
+ import { MetadataCard } from "../record-view/record-sidebar.js";
15
15
  import { RecordHeader } from "../record-view/record-header.js";
16
16
  import { SpecTable } from "../record-view/spec-table.js";
17
17
  import { KeyFacts } from "../record-view/key-facts.js";
@@ -21,8 +21,9 @@ import { isChosen, keyValue } from "./create/create-values.js";
21
21
  import { ErrorBanner } from "./error-banner.js";
22
22
  import { isMarkdown } from "../markdown/markdown-hint.js";
23
23
  import { recordTitle } from "../live/model-helpers.js";
24
+ import { contactFormat } from "../live/contact-links.js";
24
25
  import { toneFor } from "../live/columns-from-model.js";
25
- import { displayValue } from "../live/display-value.js";
26
+ import { displayValue, valueTitle } from "../live/display-value.js";
26
27
  import { filledFields } from "./filled-fields.js";
27
28
  import { Notice, useAdminMeta, usePermissions } from "./meta-gate.js";
28
29
  import { AppLink, useAppAddress } from "./use-app-address.js";
@@ -30,6 +31,7 @@ import { useProjectUi } from "./pages/project-ui.js";
30
31
  import { shownWidget } from "./field-types/shown-widget.js";
31
32
  import { WidgetPart } from "./field-types/widget-part.js";
32
33
  import { Markdown } from "../markdown/markdown.js";
34
+ import { ContactLink } from "../live/contact-link.js";
33
35
  import { FieldEditor } from "./field-editor.js";
34
36
  import { fieldMessage } from "./field-errors.js";
35
37
  import { useFocusFirstInvalid } from "./use-focus-first-invalid.js";
@@ -52,6 +54,7 @@ import { HistorySection } from "./history/history-section.js";
52
54
  import { CommentsSection } from "./comments/comments-section.js";
53
55
  import { useRecordEditor } from "./use-record-editor.js";
54
56
  import { useRelatedRecords } from "./use-related-records.js";
57
+ import { IncomingCard } from "./incoming-card.js";
55
58
  import { RelatedSection } from "./related-section.js";
56
59
  import { Fragment, useMemo } from "react";
57
60
  import { Check } from "lucide-react";
@@ -121,8 +124,7 @@ const RecordPage = ({ resource, recordKey }) => {
121
124
  };
122
125
  const LoadedRecord = ({ model, recordKey, stored, preview }) => {
123
126
  const meta = useAdminMeta();
124
- const { basePath } = useRouter();
125
- const { address, go, open } = useAppAddress();
127
+ const { address, go } = useAppAddress();
126
128
  const view = meta.views[model.name];
127
129
  const title = recordTitle(model, view, stored.record, recordKey);
128
130
  const { state: auth } = useAuth();
@@ -168,7 +170,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
168
170
  }, { replace: true }),
169
171
  onReview: () => void queryClient.invalidateQueries({ queryKey: keys.record(model.name, recordKey) })
170
172
  });
171
- const related = useRelatedRecords(model, view, meta.resources, meta.views, stored.record, basePath);
173
+ const related = useRelatedRecords(model, meta.resources, meta.views, stored.record);
172
174
  const pickTarget = (name) => {
173
175
  const field = model.fields[name];
174
176
  const target = field?.relation ? meta.resources[field.relation.resource] : void 0;
@@ -180,7 +182,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
180
182
  ...stored.record,
181
183
  ...Object.fromEntries(picked.map((field) => [field.name, fromDraft(field, editor.value(field.name))]))
182
184
  };
183
- const chosen = useRelatedRecords(model, view, meta.resources, meta.views, drafted, basePath);
185
+ const chosen = useRelatedRecords(model, meta.resources, meta.views, drafted);
184
186
  const pick = (name, next) => {
185
187
  const target = pickTarget(name);
186
188
  if (!target || !isChosen(next)) return editor.change(name, next);
@@ -279,11 +281,16 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
279
281
  const text = displayValue(field, stored.record[name], hint(name));
280
282
  const value = stored.record[name];
281
283
  const markdown = isMarkdown(field, hint(name)) && typeof value === "string" && value !== "";
284
+ const contact = field.type === "text" && typeof value === "string" && value !== "" ? contactFormat(hint(name)) : void 0;
282
285
  const builtIn = field.type === "enum" ? /* @__PURE__ */ jsx(ValueBadge, {
283
286
  value,
284
287
  hints: hint(name),
285
288
  children: text
286
- }) : markdown ? /* @__PURE__ */ jsx(Markdown, { source: value }) : /* @__PURE__ */ jsx("strong", {
289
+ }) : markdown ? /* @__PURE__ */ jsx(Markdown, { source: value }) : contact ? /* @__PURE__ */ jsx(ContactLink, {
290
+ format: contact,
291
+ value: text
292
+ }) : /* @__PURE__ */ jsx("strong", {
293
+ title: valueTitle(field, value),
287
294
  className: hint(name)?.multiline ? "whitespace-pre-line break-words" : void 0,
288
295
  children: text
289
296
  });
@@ -349,7 +356,6 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
349
356
  children: [
350
357
  /* @__PURE__ */ jsx(RecordHeader, {
351
358
  title,
352
- titleHidden: true,
353
359
  status: status ? {
354
360
  label: enumLabel(status, hint("status")?.valueLabels),
355
361
  tone: toneFor(status)
@@ -404,7 +410,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
404
410
  /* @__PURE__ */ jsxs("div", {
405
411
  className: "grid grid-cols-1 gap-8 md:grid-cols-[minmax(0,1fr)_300px] md:gap-12",
406
412
  children: [/* @__PURE__ */ jsxs("main", {
407
- className: cn("divide-y divide-border", !status && "*:first:pt-0!"),
413
+ className: "divide-y divide-border",
408
414
  children: [layout.flatMap((item, index) => {
409
415
  if (item.kind === "related") return [/* @__PURE__ */ jsx(RelatedSection, {
410
416
  item,
@@ -463,7 +469,7 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
463
469
  locked: shown !== "form"
464
470
  })]
465
471
  }), /* @__PURE__ */ jsxs("aside", {
466
- className: cn("space-y-4", status && "md:pt-8"),
472
+ className: "space-y-4 md:pt-8",
467
473
  children: [
468
474
  formControls && /* @__PURE__ */ jsx("div", {
469
475
  className: "hidden flex-col items-center gap-2 md:flex",
@@ -495,16 +501,18 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
495
501
  if (!name || !field || !visible(name)) return [];
496
502
  return [{
497
503
  label: hint(name)?.label ?? humanize(name),
498
- value: known(name) ? displayValue(field, stored.record[name], hint(name)) : /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" })
504
+ value: known(name) ? /* @__PURE__ */ jsx("span", {
505
+ title: valueTitle(field, stored.record[name]),
506
+ children: displayValue(field, stored.record[name], hint(name))
507
+ }) : /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" })
499
508
  }];
500
509
  })
501
510
  }),
502
- related.records.length > 0 && /* @__PURE__ */ jsx(SidebarCard, {
503
- title: "Related records",
504
- children: /* @__PURE__ */ jsx(RelatedRecords, {
505
- records: related.records,
506
- onNavigate: open
507
- })
511
+ !preview && /* @__PURE__ */ jsx(IncomingCard, {
512
+ model,
513
+ recordKey,
514
+ layout,
515
+ lines: linesModel
508
516
  })
509
517
  ]
510
518
  })]