@protobase/ui 0.21.7 → 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 (90) hide show
  1. package/dist/app/app.js +1 -0
  2. package/dist/app/charts/chart-view.js +3 -1
  3. package/dist/app/comments/comment-body.js +13 -0
  4. package/dist/app/comments/comment-composer.js +149 -0
  5. package/dist/app/comments/comments-section.js +78 -0
  6. package/dist/app/comments/mention-text.js +45 -0
  7. package/dist/app/comments/use-mentionable.js +18 -0
  8. package/dist/app/comments/use-post-comment.js +15 -0
  9. package/dist/app/comments/use-record-comments.js +24 -0
  10. package/dist/app/current-address.d.ts +4 -3
  11. package/dist/app/current-address.js +1 -0
  12. package/dist/app/displays/calendar-controls.js +70 -0
  13. package/dist/app/displays/calendar-day.js +33 -0
  14. package/dist/app/displays/calendar-display.js +175 -0
  15. package/dist/app/displays/calendar-dnd.js +27 -0
  16. package/dist/app/displays/calendar-event.js +73 -0
  17. package/dist/app/displays/calendar-range.js +129 -0
  18. package/dist/app/displays/list-displays.js +7 -1
  19. package/dist/app/displays/use-calendar-move.js +68 -0
  20. package/dist/app/displays/use-calendar-rows.js +35 -0
  21. package/dist/app/embedded-list/embedded-list.js +25 -21
  22. package/dist/app/embedded-list/embedded-row-targets.js +65 -0
  23. package/dist/app/export/cells.js +6 -4
  24. package/dist/app/field-editor.js +13 -1
  25. package/dist/app/import/convert.js +2 -12
  26. package/dist/app/import/locale-formats.js +2 -1
  27. package/dist/app/incoming-card.js +35 -0
  28. package/dist/app/incoming-relations.js +36 -0
  29. package/dist/app/list-chart.js +3 -1
  30. package/dist/app/list-page.js +16 -5
  31. package/dist/app/pages/field-value.js +9 -1
  32. package/dist/app/pages/record-actions.js +34 -16
  33. package/dist/app/pages/use-run-action.js +8 -6
  34. package/dist/app/record-page.js +50 -28
  35. package/dist/app/related-opens.js +22 -0
  36. package/dist/app/related-section.js +4 -1
  37. package/dist/app/runtime/runtime-status.js +5 -4
  38. package/dist/app/search/navigation-hits.js +45 -0
  39. package/dist/app/search/use-global-search.js +13 -4
  40. package/dist/app/use-app-address.d.ts +3 -1
  41. package/dist/app/use-app-address.js +8 -9
  42. package/dist/app/use-incoming-counts.js +38 -0
  43. package/dist/app/use-related-records.js +3 -10
  44. package/dist/app-shell/app-shell.js +9 -2
  45. package/dist/app-shell/index.d.ts +1 -0
  46. package/dist/app-shell/logo.d.ts +5 -1
  47. package/dist/app-shell/logo.js +10 -3
  48. package/dist/app-shell/sidebar.js +3 -2
  49. package/dist/app-shell/top-bar-slot.js +11 -0
  50. package/dist/app-shell/top-bar.d.ts +3 -0
  51. package/dist/app-shell/top-bar.js +27 -19
  52. package/dist/auth/user-to-shell.js +3 -1
  53. package/dist/data/api-provider.d.ts +20 -0
  54. package/dist/data/query-keys.js +15 -0
  55. package/dist/data-grid/cells.js +8 -1
  56. package/dist/data-grid/grid-header.js +17 -16
  57. package/dist/data-grid/header-menu.js +16 -6
  58. package/dist/data-grid/row-menu.js +10 -40
  59. package/dist/format/date.js +52 -6
  60. package/dist/format/number-text.js +16 -0
  61. package/dist/format/zone.js +62 -0
  62. package/dist/live/columns-from-model.js +2 -0
  63. package/dist/live/contact-link.js +25 -0
  64. package/dist/live/contact-links.js +42 -0
  65. package/dist/live/display-value.js +14 -4
  66. package/dist/live/field-values.js +20 -9
  67. package/dist/live/filter-string.js +30 -30
  68. package/dist/package.js +1 -1
  69. package/dist/primitives/popover-menu.js +56 -0
  70. package/dist/primitives/segmented.js +8 -3
  71. package/dist/primitives/textarea.d.ts +4 -3
  72. package/dist/primitives/textarea.js +9 -4
  73. package/dist/record-view/action-menu.js +59 -0
  74. package/dist/record-view/record-header.js +2 -6
  75. package/dist/record-view/record-sidebar.js +2 -30
  76. package/dist/shell/assets/app-B_L-OYQJ.js +69 -0
  77. package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
  78. package/dist/shell/assets/{index-CQc-60lQ.js → index-CRNWPM01.js} +1 -1
  79. package/dist/shell/assets/index-CzqIlZPP.css +2 -0
  80. package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-DZlOrjmh.js} +1 -1
  81. package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-DT_18T89.js} +1 -1
  82. package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-Bmns3gJf.js} +1 -1
  83. package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-Ci3qFCDs.js} +1 -1
  84. package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-DPgVYrS4.js} +1 -1
  85. package/dist/shell/index.html +4 -4
  86. package/dist/shell/protobase.shell.json +1 -1
  87. package/package.json +4 -4
  88. package/dist/shell/assets/app-BbVdHzEY.js +0 -69
  89. package/dist/shell/assets/export-worker-CVd5saLq.js +0 -2
  90. package/dist/shell/assets/index-C1fzTv_X.css +0 -2
@@ -0,0 +1,65 @@
1
+ import { humanize } from "../../live/naming.js";
2
+ import { recordKey } from "../../live/model-helpers.js";
3
+ import { rowAllows } from "../permissions.js";
4
+ import { relationTarget } from "../related-opens.js";
5
+ //#region src/app/embedded-list/embedded-row-targets.ts
6
+ const singularOf = (model, view) => (view?.names?.singular ?? humanize(model.name)).toLowerCase();
7
+ /**
8
+ * Where a table row of an embedded list leads, and its menu's items. A row opens its own record, or with `opens` the
9
+ * record that relation points at when it holds one; its menu then opens that record and edits, or for a user who may
10
+ * not, opens the row's own.
11
+ */
12
+ const embeddedRowTargets = ({ model, view, opens, resources, views, canUpdate }) => {
13
+ const own = (row) => ({
14
+ kind: "record",
15
+ resource: model.name,
16
+ key: recordKey(model, row)
17
+ });
18
+ const farName = opens && model.fields[opens]?.relation?.resource;
19
+ const far = farName ? resources[farName] : void 0;
20
+ const other = (row) => {
21
+ const key = opens && far ? relationTarget(row, opens) : void 0;
22
+ return far && key !== void 0 ? {
23
+ kind: "record",
24
+ resource: far.name,
25
+ key
26
+ } : void 0;
27
+ };
28
+ const address = (row) => other(row) ?? own(row);
29
+ const singular = singularOf(model, view);
30
+ const actions = (linkTo) => {
31
+ const openOwn = {
32
+ label: `Open ${singular}`,
33
+ link: (row) => linkTo(own(row))
34
+ };
35
+ if (!far) return [openOwn];
36
+ const editable = (row) => canUpdate && rowAllows(row, "update");
37
+ return [
38
+ {
39
+ label: `Open ${singularOf(far, views[far.name])}`,
40
+ available: (row) => other(row) !== void 0,
41
+ link: (row) => linkTo(other(row))
42
+ },
43
+ {
44
+ label: `Edit ${singular}`,
45
+ available: editable,
46
+ link: (row) => linkTo({
47
+ kind: "record",
48
+ resource: model.name,
49
+ key: recordKey(model, row),
50
+ edit: true
51
+ })
52
+ },
53
+ {
54
+ ...openOwn,
55
+ available: (row) => !editable(row)
56
+ }
57
+ ];
58
+ };
59
+ return {
60
+ address,
61
+ actions
62
+ };
63
+ };
64
+ //#endregion
65
+ export { embeddedRowTargets };
@@ -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
@@ -31,6 +31,7 @@ import { SignInPrompt } from "../auth/sign-in-prompt.js";
31
31
  import { relatedColumnLoads } from "./related-dots/dot-card.js";
32
32
  import { useDisplayRows } from "./displays/use-display-rows.js";
33
33
  import { BoardDisplay } from "./displays/board-display.js";
34
+ import { CalendarDisplay } from "./displays/calendar-display.js";
34
35
  import { CustomDisplay } from "./displays/custom-display.js";
35
36
  import { MapDisplay } from "./displays/map-display.js";
36
37
  import { DisplaySwitch, SortMenu } from "./displays/display-controls.js";
@@ -115,7 +116,7 @@ const ListPage = ({ address }) => {
115
116
  };
116
117
  const LoadedList = ({ model, address, scope, heading }) => {
117
118
  const meta = useAdminMeta();
118
- const { set, go, href } = useAppAddress();
119
+ const { set, listTo, go, href } = useAppAddress();
119
120
  const resource = model.name;
120
121
  const view = meta.views[resource];
121
122
  const fields = useMemo(() => widgetFields(view, model), [view, model]);
@@ -202,10 +203,11 @@ const LoadedList = ({ model, address, scope, heading }) => {
202
203
  setFilter(state, text ? [...others, where$1.search(text)] : others);
203
204
  };
204
205
  const plural = view?.names?.plural ?? humanize(resource);
205
- const sortMenu = display && /* @__PURE__ */ jsx(SortMenu, {
206
+ const sorted = display?.kind === "calendar" ? void 0 : display;
207
+ const sortMenu = sorted && /* @__PURE__ */ jsx(SortMenu, {
206
208
  model,
207
209
  view,
208
- display,
210
+ display: sorted,
209
211
  orderBy,
210
212
  onChange: (next) => set({ orderBy: next })
211
213
  });
@@ -234,12 +236,12 @@ const LoadedList = ({ model, address, scope, heading }) => {
234
236
  onRemove: (key) => setFilter(removeChip(state, key)),
235
237
  onClear: () => setFilter(emptyFilterState)
236
238
  });
237
- const phoneSort = display && /* @__PURE__ */ jsx(FilterSection, {
239
+ const phoneSort = sorted && /* @__PURE__ */ jsx(FilterSection, {
238
240
  title: "Sort",
239
241
  children: /* @__PURE__ */ jsx(SortMenu, {
240
242
  model,
241
243
  view,
242
- display,
244
+ display: sorted,
243
245
  orderBy,
244
246
  onChange: (next) => set({ orderBy: next }),
245
247
  className: "min-h-11 w-full"
@@ -425,6 +427,15 @@ const LoadedList = ({ model, address, scope, heading }) => {
425
427
  filter: loadFilter,
426
428
  orderBy
427
429
  });
430
+ if (display.kind === "calendar") return /* @__PURE__ */ jsx(CalendarDisplay, {
431
+ model,
432
+ view,
433
+ display,
434
+ fields: loaded,
435
+ filter: loadFilter,
436
+ at: address.calendar,
437
+ linkTo: (calendar) => listTo({ calendar })
438
+ });
428
439
  return /* @__PURE__ */ jsx(CustomDisplay, {
429
440
  display,
430
441
  list: displayRows
@@ -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 };
@@ -12,10 +12,10 @@ import { fillAddress } from "@protobase/schema/internal";
12
12
  const message = (error) => error instanceof Error ? error.message : "Something went wrong";
13
13
  /**
14
14
  * The named actions of `resource`'s view, ready to run on `scope` (the record they act on): built-in behaviour first
15
- * (`update`, `delete`, `link`), else the handler the project registered under the action's name. An update whose values
16
- * the record holds already is left out, and so is a link to a URL the record holds while it holds none. `blocked` says
17
- * why one cannot run; `afterRun` hears about every action that succeeded. `submit` runs one with the answers to its
18
- * inputs and throws its failure, for the dialog to show.
15
+ * (`update`, `delete`, `link`, and `ai`, which the server runs), else the handler the project registered under the
16
+ * action's name. An update whose values the record holds already is left out, and so is a link to a URL the record
17
+ * holds while it holds none. `blocked` says why one cannot run; `afterRun` hears about every action that succeeded.
18
+ * `submit` runs one with the answers to its inputs and throws its failure, for the dialog to show.
19
19
  */
20
20
  const useActionRunner = (resource, scope, afterRun) => {
21
21
  const { views } = useAdminMeta();
@@ -30,8 +30,9 @@ const useActionRunner = (resource, scope, afterRun) => {
30
30
  const actions = (views[resource]?.actions ?? []).flatMap((action) => actionFor(action, scope?.record) ?? []);
31
31
  const blocked = (action) => {
32
32
  const kind = action.run?.kind;
33
- if ((kind === "update" || kind === "delete") && !scope) return "Needs a record";
34
- if (kind === "update" && (!can(permissions, "update") || scope?.permissions?.update === false)) return "You cannot change this record";
33
+ if ((kind === "update" || kind === "delete" || kind === "ai") && !scope) return "Needs a record";
34
+ if ((kind === "update" || kind === "ai") && (!can(permissions, "update") || scope?.permissions?.update === false)) return "You cannot change this record";
35
+ if (action.run?.kind === "ai" && scope?.permissions?.fields?.[action.run.writes] === "read") return "You cannot change this record";
35
36
  if (kind === "delete" && (!can(permissions, "delete") || scope?.permissions?.delete === false)) return "You cannot delete this record";
36
37
  if (!action.run && !handlers?.[action.name]) return `The app has no handler for "${action.name}"`;
37
38
  const custom = action.inputs?.find((input) => input.kind === "custom" && !components?.[input.component ?? ""]);
@@ -46,6 +47,7 @@ const useActionRunner = (resource, scope, afterRun) => {
46
47
  }
47
48
  if (run?.kind === "update") await client.update(resource, scope.key, run.values, scope.etag ?? "*");
48
49
  else if (run?.kind === "delete") await client.remove(resource, scope.key, { etag: scope.etag });
50
+ else if (run?.kind === "ai") await client.generate(resource, scope.key, action.name);
49
51
  else await handlers[action.name]({
50
52
  resource,
51
53
  record: scope?.record,