@protobase/ui 0.21.8 → 0.21.10

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 (65) hide show
  1. package/dist/account/account-security-page.js +5 -4
  2. package/dist/app/app.js +1 -0
  3. package/dist/app/charts/chart-view.js +3 -1
  4. package/dist/app/displays/calendar-controls.js +2 -1
  5. package/dist/app/displays/calendar-display.js +2 -1
  6. package/dist/app/displays/calendar-event.js +2 -1
  7. package/dist/app/displays/calendar-range.js +18 -14
  8. package/dist/app/export/cells.js +6 -4
  9. package/dist/app/field-editor.js +13 -1
  10. package/dist/app/import/convert.js +2 -12
  11. package/dist/app/import/locale-formats.js +2 -1
  12. package/dist/app/incoming-card.js +35 -0
  13. package/dist/app/incoming-relations.js +36 -0
  14. package/dist/app/list-chart.js +3 -1
  15. package/dist/app/pages/field-value.js +9 -1
  16. package/dist/app/pages/record-actions.js +34 -16
  17. package/dist/app/record-page.js +26 -18
  18. package/dist/app/runtime/runtime-status.js +5 -4
  19. package/dist/app/search/navigation-hits.js +45 -0
  20. package/dist/app/search/use-global-search.js +13 -4
  21. package/dist/app/use-incoming-counts.js +38 -0
  22. package/dist/app/use-related-records.js +3 -10
  23. package/dist/app-shell/app-shell.js +9 -2
  24. package/dist/app-shell/index.d.ts +1 -0
  25. package/dist/app-shell/logo.d.ts +5 -1
  26. package/dist/app-shell/logo.js +10 -3
  27. package/dist/app-shell/sidebar.js +3 -2
  28. package/dist/app-shell/top-bar-slot.js +11 -0
  29. package/dist/app-shell/top-bar.d.ts +3 -0
  30. package/dist/app-shell/top-bar.js +27 -19
  31. package/dist/auth/auth-messages.js +2 -1
  32. package/dist/auth/auth-provider.js +8 -3
  33. package/dist/data-grid/cells.js +8 -1
  34. package/dist/data-grid/grid-header.js +17 -16
  35. package/dist/data-grid/header-menu.js +16 -6
  36. package/dist/data-grid/row-menu.js +2 -39
  37. package/dist/format/date.js +29 -9
  38. package/dist/format/number-text.js +16 -0
  39. package/dist/format/zone.js +62 -0
  40. package/dist/live/columns-from-model.js +2 -0
  41. package/dist/live/contact-link.js +25 -0
  42. package/dist/live/contact-links.js +42 -0
  43. package/dist/live/display-value.js +14 -4
  44. package/dist/live/field-values.js +20 -9
  45. package/dist/live/filter-string.js +30 -30
  46. package/dist/package.js +1 -1
  47. package/dist/primitives/popover-menu.js +56 -0
  48. package/dist/record-view/action-menu.js +59 -0
  49. package/dist/record-view/record-header.js +2 -6
  50. package/dist/record-view/record-sidebar.js +2 -30
  51. package/dist/shell/assets/app-9imuOKuW.js +69 -0
  52. package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
  53. package/dist/shell/assets/{index-xN6_oZU2.js → index-C_o0i6I2.js} +1 -1
  54. package/dist/shell/assets/index-CzqIlZPP.css +2 -0
  55. package/dist/shell/assets/{maplibre-Ba9RapjY.js → maplibre-DLYsHwYU.js} +1 -1
  56. package/dist/shell/assets/{markdown-content-BUxrbovf.js → markdown-content-B410ow-t.js} +1 -1
  57. package/dist/shell/assets/{record-map-14zotZ2s.js → record-map-DvgWFyLn.js} +1 -1
  58. package/dist/shell/assets/{records-map-wi-0pXqF.js → records-map-CLj0uHGq.js} +1 -1
  59. package/dist/shell/assets/{shared-protobase-ui-BVwhKZk9.js → shared-protobase-ui-Bf1h2ktk.js} +1 -1
  60. package/dist/shell/index.html +4 -4
  61. package/dist/shell/protobase.shell.json +1 -1
  62. package/package.json +4 -4
  63. package/dist/shell/assets/app-C01lazBK.js +0 -69
  64. package/dist/shell/assets/export-worker-BnOpS_4Q.js +0 -2
  65. package/dist/shell/assets/index-CBGigFxb.css +0 -2
@@ -17,7 +17,8 @@ const takeLinkError = () => {
17
17
  };
18
18
  /** The signed-in person's passkeys, two-factor authentication and connected accounts, within what the sign-in policy allows. */
19
19
  const AccountSecurityPage = ({ email }) => {
20
- const { session, signInProviders, linkProvider } = useAuth();
20
+ const { session, signInProviders, socialProviders, linkProvider } = useAuth();
21
+ const linkable = signInProviders.filter((provider) => socialProviders.includes(provider.id));
21
22
  const { account } = session;
22
23
  const [loaded, setLoaded] = useState();
23
24
  const [error, setError] = useState();
@@ -61,11 +62,11 @@ const AccountSecurityPage = ({ email }) => {
61
62
  onNewBackupCodes: (password) => account.newBackupCodes(password),
62
63
  onChanged: reload
63
64
  }),
64
- signInProviders.length > 0 && /* @__PURE__ */ jsx(SignInProvidersSection, {
65
- providers: signInProviders,
65
+ linkable.length > 0 && /* @__PURE__ */ jsx(SignInProvidersSection, {
66
+ providers: linkable,
66
67
  linked: loaded.linked,
67
68
  onConnect: linkProvider,
68
- error: linkError && providerMessage(linkError, signInProviders.length === 1 ? signInProviders[0].name : "the provider")
69
+ error: linkError && providerMessage(linkError, linkable.length === 1 ? linkable[0].name : "the provider")
69
70
  })
70
71
  ] }) : error ? /* @__PURE__ */ jsx("p", {
71
72
  role: "alert",
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 };