@protobase/ui 0.21.6 → 0.21.8

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 (96) hide show
  1. package/dist/app/app.d.ts +3 -3
  2. package/dist/app/app.js +32 -13
  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/create/create-page.js +4 -4
  11. package/dist/app/current-address.d.ts +4 -3
  12. package/dist/app/current-address.js +1 -0
  13. package/dist/app/custom-section.js +1 -1
  14. package/dist/app/displays/board-columns.js +1 -1
  15. package/dist/app/displays/board-display.js +1 -1
  16. package/dist/app/displays/calendar-controls.js +69 -0
  17. package/dist/app/displays/calendar-day.js +33 -0
  18. package/dist/app/displays/calendar-display.js +174 -0
  19. package/dist/app/displays/calendar-dnd.js +27 -0
  20. package/dist/app/displays/calendar-event.js +72 -0
  21. package/dist/app/displays/calendar-range.js +125 -0
  22. package/dist/app/displays/list-displays.js +8 -2
  23. package/dist/app/displays/map-display.js +1 -1
  24. package/dist/app/displays/record-card.js +2 -2
  25. package/dist/app/displays/use-board-column.js +1 -1
  26. package/dist/app/displays/use-board-move.js +1 -1
  27. package/dist/app/displays/use-calendar-move.js +68 -0
  28. package/dist/app/displays/use-calendar-rows.js +35 -0
  29. package/dist/app/displays/use-map-records.js +1 -1
  30. package/dist/app/embedded-list/embedded-list.js +26 -22
  31. package/dist/app/embedded-list/embedded-row-targets.js +65 -0
  32. package/dist/app/embedded-list/use-embedded-rows.js +1 -1
  33. package/dist/app/gallery/gallery-section.js +1 -1
  34. package/dist/app/gallery/lightbox.js +1 -1
  35. package/dist/app/hand-order/hand-sorted-list.js +3 -3
  36. package/dist/app/hand-order/use-hand-order.js +2 -2
  37. package/dist/app/import/import-dialog.js +1 -1
  38. package/dist/app/list-edit/use-list-edits.js +1 -1
  39. package/dist/app/list-page.js +20 -9
  40. package/dist/app/map/map-section.js +1 -1
  41. package/dist/app/pages/action-inputs/action-inputs-dialog.js +3 -3
  42. package/dist/app/pages/blocks/card-row.js +1 -1
  43. package/dist/app/pages/blocks/modal-form.js +3 -3
  44. package/dist/app/pages/blocks/record-card.js +1 -1
  45. package/dist/app/pages/field-value.js +1 -1
  46. package/dist/app/pages/use-run-action.js +9 -7
  47. package/dist/app/record-lines.js +1 -1
  48. package/dist/app/record-page.js +29 -15
  49. package/dist/app/record-row/record-row-body.js +1 -1
  50. package/dist/app/record-row/saved-record-row.js +1 -1
  51. package/dist/app/related-dots/related-dots.js +1 -1
  52. package/dist/app/related-opens.js +22 -0
  53. package/dist/app/related-section.js +5 -2
  54. package/dist/app/relation-picker.js +1 -1
  55. package/dist/app/search/search-model.js +1 -1
  56. package/dist/app/searches/saved-search-rows.js +2 -2
  57. package/dist/app/searches/saved-searches.js +2 -2
  58. package/dist/app/searches/update-search-dialog.js +1 -1
  59. package/dist/app/searches/use-save-search.js +1 -1
  60. package/dist/app/searches/use-saved-searches.js +1 -1
  61. package/dist/app/use-app-address.d.ts +3 -1
  62. package/dist/app/use-app-address.js +9 -10
  63. package/dist/assistant-dock/widget-view.js +1 -1
  64. package/dist/auth/auth-layout.d.ts +4 -2
  65. package/dist/auth/auth-layout.js +4 -3
  66. package/dist/auth/user-to-shell.js +3 -1
  67. package/dist/data/api-provider.d.ts +26 -0
  68. package/dist/data/query-keys.js +18 -1
  69. package/dist/data-grid/row-menu.js +8 -1
  70. package/dist/format/date.js +27 -1
  71. package/dist/forms/prove-human.js +24 -0
  72. package/dist/forms/public-form-page.js +142 -0
  73. package/dist/forms/use-form-address.js +11 -0
  74. package/dist/index.js +7 -7
  75. package/dist/live/columns-from-model.js +1 -1
  76. package/dist/live/use-filter-config.js +1 -1
  77. package/dist/package.js +1 -1
  78. package/dist/plugins/plugin-gate.js +1 -1
  79. package/dist/primitives/segmented.js +8 -3
  80. package/dist/primitives/textarea.d.ts +4 -3
  81. package/dist/primitives/textarea.js +9 -4
  82. package/dist/shell/assets/app-C01lazBK.js +69 -0
  83. package/dist/shell/assets/{export-worker-CVd5saLq.js → export-worker-BnOpS_4Q.js} +1 -1
  84. package/dist/shell/assets/index-CBGigFxb.css +2 -0
  85. package/dist/shell/assets/{index-BztYGbYc.js → index-xN6_oZU2.js} +1 -1
  86. package/dist/shell/assets/{maplibre-CVOEAJ62.js → maplibre-Ba9RapjY.js} +1 -1
  87. package/dist/shell/assets/{markdown-content-Bg8FC_aK.js → markdown-content-BUxrbovf.js} +1 -1
  88. package/dist/shell/assets/{record-map-ChUVZLR6.js → record-map-14zotZ2s.js} +1 -1
  89. package/dist/shell/assets/{records-map-BAI2D9VS.js → records-map-wi-0pXqF.js} +1 -1
  90. package/dist/shell/assets/shared-protobase-ui-BVwhKZk9.js +1 -0
  91. package/dist/shell/index.html +4 -4
  92. package/dist/shell/protobase.shell.json +1 -1
  93. package/package.json +5 -4
  94. package/dist/shell/assets/app-Dfqcc3td.js +0 -69
  95. package/dist/shell/assets/index-C1fzTv_X.css +0 -2
  96. package/dist/shell/assets/shared-protobase-ui-CzT3bJ35.js +0 -1
package/dist/app/app.d.ts CHANGED
@@ -29,9 +29,9 @@ export type AppProps = {
29
29
  pluginLoader?: PluginLoader;
30
30
  };
31
31
  /**
32
- * The admin: the sign-in route first, `/-/sign-in`, or browsing as a visitor when the project has an anonymous role,
33
- * then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its
34
- * parent, in the theme this browser chose, light, dark or the system's.
32
+ * The admin: a public form at `/-/forms/<name>` for anyone, the sign-in route first, `/-/sign-in`, or browsing as a
33
+ * visitor when the project's anonymous role reads, then a sidebar from `/meta`, `/:page` composed pages, `/:resource`
34
+ * lists and `/:resource/:key` records. Fills its parent, in the theme this browser chose, light, dark or the system's.
35
35
  */
36
36
  export declare const App: ({ auth, workspace, basePath, initialUrl, pluginLoader, ...rest }: AppProps) => import("react").JSX.Element;
37
37
  //#endregion
package/dist/app/app.js CHANGED
@@ -13,18 +13,20 @@ import { PluginSourceProvider } from "../plugins/plugin-source.js";
13
13
  import { AuthGate } from "../auth/auth-gate.js";
14
14
  import { isAdmin, userToShell } from "../auth/user-to-shell.js";
15
15
  import { useOpenedRecord } from "../data/use-record.js";
16
- import { recordTitle } from "../live/model-helpers.js";
17
16
  import { humanize } from "../live/naming.js";
18
- import { ThemeProvider, useTheme } from "../theme/theme-provider.js";
19
17
  import { ToastProvider } from "../toasts/toast-provider.js";
20
- import { useProjectUi } from "./pages/project-ui.js";
21
- import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
22
- import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
23
- import { breadcrumbFor } from "./breadcrumb-for.js";
24
18
  import { documentTitle, titleParts, useDocumentTitle } from "./document-title.js";
19
+ import { recordTitle } from "../live/model-helpers.js";
25
20
  import { sourceStatusOf } from "../data/with-sources.js";
26
21
  import { MetaGate, Notice, useAdminMeta } from "./meta-gate.js";
27
22
  import { useAppAddress } from "./use-app-address.js";
23
+ import { ProjectUiContext, useProjectUi } from "./pages/project-ui.js";
24
+ import { PublicFormPage } from "../forms/public-form-page.js";
25
+ import { useFormAddress } from "../forms/use-form-address.js";
26
+ import { ThemeProvider, useTheme } from "../theme/theme-provider.js";
27
+ import { AssistantButton, AssistantPanel } from "./assistant-shell.js";
28
+ import { bottomBarFromMeta } from "./bottom-bar-from-meta.js";
29
+ import { breadcrumbFor } from "./breadcrumb-for.js";
28
30
  import { ComposedPage } from "./pages/composed-page.js";
29
31
  import { CreateRoute } from "./create/create-route.js";
30
32
  import { ListPage } from "./list-page.js";
@@ -278,10 +280,22 @@ const Authenticated = ({ baseUrl, client, sources, workspace, sidebarMode, ui, a
278
280
  }) }) })
279
281
  });
280
282
  };
283
+ const PublicFormGate = ({ baseUrl, client, ui, children }) => {
284
+ const address = useFormAddress();
285
+ if (!address) return children;
286
+ return /* @__PURE__ */ jsx(ApiProvider, {
287
+ baseUrl,
288
+ client,
289
+ children: /* @__PURE__ */ jsx(ProjectUiContext.Provider, {
290
+ value: ui ?? {},
291
+ children: /* @__PURE__ */ jsx(PublicFormPage, { name: address.name }, address.name)
292
+ })
293
+ });
294
+ };
281
295
  /**
282
- * The admin: the sign-in route first, `/-/sign-in`, or browsing as a visitor when the project has an anonymous role,
283
- * then a sidebar from `/meta`, `/:page` composed pages, `/:resource` lists and `/:resource/:key` records. Fills its
284
- * parent, in the theme this browser chose, light, dark or the system's.
296
+ * The admin: a public form at `/-/forms/<name>` for anyone, the sign-in route first, `/-/sign-in`, or browsing as a
297
+ * visitor when the project's anonymous role reads, then a sidebar from `/meta`, `/:page` composed pages, `/:resource`
298
+ * lists and `/:resource/:key` records. Fills its parent, in the theme this browser chose, light, dark or the system's.
285
299
  */
286
300
  const App = ({ auth, workspace, basePath, initialUrl, pluginLoader, ...rest }) => /* @__PURE__ */ jsx(ThemeProvider, { children: /* @__PURE__ */ jsx(AuthProvider, {
287
301
  session: auth,
@@ -290,11 +304,16 @@ const App = ({ auth, workspace, basePath, initialUrl, pluginLoader, ...rest }) =
290
304
  initialUrl,
291
305
  children: /* @__PURE__ */ jsx(PluginSourceProvider, {
292
306
  ...pluginLoader && { loader: pluginLoader },
293
- children: /* @__PURE__ */ jsx(AuthGate, {
294
- workspace,
295
- children: /* @__PURE__ */ jsx(Authenticated, {
307
+ children: /* @__PURE__ */ jsx(PublicFormGate, {
308
+ baseUrl: rest.baseUrl,
309
+ client: rest.client,
310
+ ui: rest.ui,
311
+ children: /* @__PURE__ */ jsx(AuthGate, {
296
312
  workspace,
297
- ...rest
313
+ children: /* @__PURE__ */ jsx(Authenticated, {
314
+ workspace,
315
+ ...rest
316
+ })
298
317
  })
299
318
  })
300
319
  })
@@ -0,0 +1,13 @@
1
+ import { bodyParts } from "./mention-text.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/app/comments/comment-body.tsx
4
+ /** A comment's text as written, line breaks kept, each `@{name}` of someone it mentions picked out. */
5
+ const CommentBody = ({ comment }) => /* @__PURE__ */ jsx("p", {
6
+ className: "whitespace-pre-wrap break-words",
7
+ children: bodyParts(comment.body, comment.mentions).map((part, index) => "mention" in part ? /* @__PURE__ */ jsxs("span", {
8
+ className: "font-medium text-primary",
9
+ children: ["@", part.mention]
10
+ }, index) : part.text)
11
+ });
12
+ //#endregion
13
+ export { CommentBody };
@@ -0,0 +1,149 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { Button } from "../../primitives/button.js";
3
+ import { useToast } from "../../toasts/toast-provider.js";
4
+ import { Textarea } from "../../primitives/textarea.js";
5
+ import { refusalReason } from "../displays/board-columns.js";
6
+ import { menuKeyTarget } from "../../primitives/menu-keys.js";
7
+ import { activeMention, insertMention, mentionedIds } from "./mention-text.js";
8
+ import { useMentionable } from "./use-mentionable.js";
9
+ import { usePostComment } from "./use-post-comment.js";
10
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
11
+ import { jsx, jsxs } from "react/jsx-runtime";
12
+ //#region src/app/comments/comment-composer.tsx
13
+ /** The most people the list offers; few enough that it never needs a scrollbar of its own. */
14
+ const shownPeople = 8;
15
+ /**
16
+ * The box a comment is written in. Typing `@` lists the people who may read the record by the name typed after it:
17
+ * the arrows move, Enter or Tab picks, Escape closes. ⌘/Ctrl+Enter or the button posts.
18
+ */
19
+ const CommentComposer = ({ resource, recordKey }) => {
20
+ const [text, setText] = useState("");
21
+ const [caret, setCaret] = useState(0);
22
+ const [focused, setFocused] = useState(false);
23
+ const [picked, setPicked] = useState([]);
24
+ const [active, setActive] = useState(0);
25
+ const [closed, setClosed] = useState();
26
+ const box = useRef(null);
27
+ const nextCaret = useRef(void 0);
28
+ const listId = useId();
29
+ const toast = useToast();
30
+ const post = usePostComment(resource, recordKey);
31
+ const mention = activeMention(text, caret);
32
+ const open = focused && mention !== void 0 && mention.start !== closed;
33
+ const people = useMentionable(resource, recordKey, open ? mention.query : void 0);
34
+ const options = open ? (people.data ?? []).slice(0, shownPeople) : [];
35
+ const listed = open && people.data !== void 0;
36
+ const optionId = (index) => `${listId}-${index}`;
37
+ useEffect(() => setActive(0), [mention?.query]);
38
+ const mentionStart = mention?.start;
39
+ useEffect(() => {
40
+ if (mentionStart === void 0) setClosed(void 0);
41
+ }, [mentionStart]);
42
+ useLayoutEffect(() => {
43
+ if (nextCaret.current === void 0 || !box.current) return;
44
+ box.current.setSelectionRange(nextCaret.current, nextCaret.current);
45
+ setCaret(nextCaret.current);
46
+ nextCaret.current = void 0;
47
+ }, [text]);
48
+ const pick = (user) => {
49
+ if (!mention) return;
50
+ const next = insertMention(text, mention, user.name);
51
+ nextCaret.current = next.caret;
52
+ setText(next.text);
53
+ setPicked((current) => [...current, user]);
54
+ };
55
+ const submit = () => {
56
+ if (text.trim() === "" || post.isPending) return;
57
+ post.mutate({
58
+ body: text,
59
+ mentions: mentionedIds(text, picked)
60
+ }, {
61
+ onSuccess: () => {
62
+ setText("");
63
+ setPicked([]);
64
+ },
65
+ onError: (error) => toast.show({
66
+ state: "error",
67
+ title: "Could not post the comment",
68
+ description: refusalReason(error)
69
+ })
70
+ });
71
+ };
72
+ const onKeyDown = (event) => {
73
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
74
+ event.preventDefault();
75
+ submit();
76
+ return;
77
+ }
78
+ if (!open) return;
79
+ const target = menuKeyTarget(event.key, active, options.length);
80
+ if (target !== void 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
81
+ event.preventDefault();
82
+ setActive(target);
83
+ return;
84
+ }
85
+ if ((event.key === "Enter" || event.key === "Tab") && options[active]) {
86
+ event.preventDefault();
87
+ pick(options[active]);
88
+ return;
89
+ }
90
+ if (event.key === "Escape") {
91
+ event.preventDefault();
92
+ event.stopPropagation();
93
+ setClosed(mention.start);
94
+ }
95
+ };
96
+ return /* @__PURE__ */ jsxs("div", {
97
+ className: "flex flex-col gap-2",
98
+ children: [/* @__PURE__ */ jsxs("div", {
99
+ className: "relative",
100
+ children: [/* @__PURE__ */ jsx(Textarea, {
101
+ ref: box,
102
+ role: "combobox",
103
+ "aria-label": "Comment",
104
+ "aria-multiline": true,
105
+ "aria-autocomplete": "list",
106
+ "aria-expanded": listed,
107
+ "aria-controls": listId,
108
+ "aria-activedescendant": listed && options[active] ? optionId(active) : void 0,
109
+ placeholder: "Write a comment, @ to mention someone",
110
+ value: text,
111
+ onChange: (event) => {
112
+ setText(event.target.value);
113
+ setCaret(event.target.selectionStart);
114
+ },
115
+ onSelect: (event) => setCaret(event.currentTarget.selectionStart),
116
+ onFocus: () => setFocused(true),
117
+ onBlur: () => setFocused(false),
118
+ onKeyDown
119
+ }), listed && /* @__PURE__ */ jsxs("ul", {
120
+ id: listId,
121
+ role: "listbox",
122
+ "aria-label": "People to mention",
123
+ className: "absolute z-30 mt-1 w-full max-w-sm rounded-md border bg-background py-1 shadow-pop",
124
+ onMouseDown: (event) => event.preventDefault(),
125
+ children: [options.map((user, index) => /* @__PURE__ */ jsx("li", {
126
+ id: optionId(index),
127
+ role: "option",
128
+ "aria-selected": index === active,
129
+ className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted"),
130
+ onMouseMove: () => index !== active && setActive(index),
131
+ onClick: () => pick(user),
132
+ children: user.name
133
+ }, user.id)), options.length === 0 && /* @__PURE__ */ jsx("li", {
134
+ className: "px-3 py-1.5 text-muted-foreground",
135
+ children: "No one found"
136
+ })]
137
+ })]
138
+ }), /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
139
+ variant: "primary",
140
+ size: "sm",
141
+ disabled: text.trim() === "",
142
+ loading: post.isPending,
143
+ onClick: submit,
144
+ children: "Comment"
145
+ }) })]
146
+ });
147
+ };
148
+ //#endregion
149
+ export { CommentComposer };
@@ -0,0 +1,78 @@
1
+ import { Skeleton } from "../../primitives/skeleton.js";
2
+ import { Avatar } from "../../primitives/avatar.js";
3
+ import { formatDateTime, formatRelativeTime } from "../../format/date.js";
4
+ import { Button } from "../../primitives/button.js";
5
+ import { hueOf, initialsOf } from "../../auth/user-to-shell.js";
6
+ import { Section } from "../../record-view/section.js";
7
+ import { ErrorBanner } from "../error-banner.js";
8
+ import { CommentBody } from "./comment-body.js";
9
+ import { CommentComposer } from "./comment-composer.js";
10
+ import { useRecordComments } from "./use-record-comments.js";
11
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
+ //#region src/app/comments/comments-section.tsx
13
+ const Loading = () => /* @__PURE__ */ jsx("div", {
14
+ className: "flex flex-col gap-2",
15
+ children: Array.from({ length: 3 }, (_, index) => /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }, index))
16
+ });
17
+ const Comment = ({ comment }) => {
18
+ const author = comment.author.name ?? comment.author.id;
19
+ const at = Date.parse(comment.createdAt);
20
+ return /* @__PURE__ */ jsxs("li", {
21
+ className: "flex gap-3 py-3 first:pt-0 last:pb-0",
22
+ children: [/* @__PURE__ */ jsx(Avatar, {
23
+ initials: initialsOf(author) || "?",
24
+ hue: hueOf(comment.author.id),
25
+ size: "md",
26
+ className: "ring-0"
27
+ }), /* @__PURE__ */ jsxs("div", {
28
+ className: "flex min-w-0 flex-1 flex-col gap-0.5",
29
+ children: [/* @__PURE__ */ jsxs("p", { children: [
30
+ /* @__PURE__ */ jsx("span", {
31
+ className: "font-medium text-foreground",
32
+ children: author
33
+ }),
34
+ " ",
35
+ /* @__PURE__ */ jsxs("time", {
36
+ dateTime: comment.createdAt,
37
+ title: formatDateTime(at),
38
+ className: "text-muted-foreground",
39
+ children: ["· ", formatRelativeTime(Math.max(0, Math.round((Date.now() - at) / 1e3)))]
40
+ })
41
+ ] }), /* @__PURE__ */ jsx(CommentBody, { comment })]
42
+ })]
43
+ });
44
+ };
45
+ /**
46
+ * The record's comments, oldest first with the newest just above the box, and Show earlier comments for those before;
47
+ * `canWrite` false, for a visitor who is not signed in, leaves out the box.
48
+ */
49
+ const CommentsSection = ({ item, resource, recordKey, canWrite }) => {
50
+ const comments = useRecordComments(resource, recordKey);
51
+ return /* @__PURE__ */ jsxs(Section, {
52
+ title: item.title,
53
+ children: [comments.isPending ? /* @__PURE__ */ jsx(Loading, {}) : comments.error ? /* @__PURE__ */ jsx(ErrorBanner, {
54
+ title: "Could not load the comments",
55
+ error: comments.error
56
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [comments.hasNextPage && /* @__PURE__ */ jsx(Button, {
57
+ size: "sm",
58
+ className: "mb-4",
59
+ disabled: comments.isFetchingNextPage,
60
+ onClick: () => void comments.fetchNextPage(),
61
+ children: "Show earlier comments"
62
+ }), comments.items.length === 0 ? /* @__PURE__ */ jsx("p", {
63
+ className: "text-muted-foreground",
64
+ children: "No comments yet."
65
+ }) : /* @__PURE__ */ jsx("ol", {
66
+ className: "divide-y divide-border",
67
+ children: comments.items.map((comment) => /* @__PURE__ */ jsx(Comment, { comment }, comment.id))
68
+ })] }), canWrite && /* @__PURE__ */ jsx("div", {
69
+ className: "mt-4",
70
+ children: /* @__PURE__ */ jsx(CommentComposer, {
71
+ resource,
72
+ recordKey
73
+ })
74
+ })]
75
+ });
76
+ };
77
+ //#endregion
78
+ export { CommentsSection };
@@ -0,0 +1,45 @@
1
+ //#region src/app/comments/mention-text.ts
2
+ /** The mention the caret is in: an `@` that starts the text or follows whitespace, with no whitespace up to the caret. */
3
+ const activeMention = (text, caret) => {
4
+ const match = /(?:^|\s)@([^\s@]*)$/.exec(text.slice(0, caret));
5
+ return match ? {
6
+ start: caret - match[1].length - 1,
7
+ query: match[1]
8
+ } : void 0;
9
+ };
10
+ /** The text with the active mention written out as `@{name} `, and the caret after it. */
11
+ const insertMention = (text, active, name) => {
12
+ const rest = text.slice(active.start + 1 + active.query.length);
13
+ const written = `@${name}${rest.startsWith(" ") ? "" : " "}`;
14
+ return {
15
+ text: `${text.slice(0, active.start)}${written}${rest}`,
16
+ caret: active.start + written.length + (rest.startsWith(" ") ? 1 : 0)
17
+ };
18
+ };
19
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ const mentionPattern = (names) => {
21
+ const unique = [...new Set(names)].filter(Boolean).sort((a, b) => b.length - a.length);
22
+ return unique.length === 0 ? void 0 : new RegExp(`(?<![\\p{L}\\p{N}_@])@(${unique.map(escaped).join("|")})(?![\\p{L}\\p{N}_])`, "gu");
23
+ };
24
+ /** The ids of the picked users whose `@{name}` is still in the text, each once. */
25
+ const mentionedIds = (text, picked) => {
26
+ const pattern = mentionPattern(picked.map((user) => user.name));
27
+ const written = new Set(pattern ? [...text.matchAll(pattern)].map((match) => match[1]) : []);
28
+ return [...new Set(picked.filter((user) => written.has(user.name)).map((user) => user.id))];
29
+ };
30
+ /** A comment's text split into plain text and the `@{name}` of each person it mentions whose name is known. */
31
+ const bodyParts = (body, mentions) => {
32
+ const pattern = mentionPattern(mentions.flatMap((user) => user.name ? [user.name] : []));
33
+ if (!pattern) return [{ text: body }];
34
+ const parts = [];
35
+ let at = 0;
36
+ for (const match of body.matchAll(pattern)) {
37
+ if (match.index > at) parts.push({ text: body.slice(at, match.index) });
38
+ parts.push({ mention: match[1] });
39
+ at = match.index + match[0].length;
40
+ }
41
+ if (at < body.length) parts.push({ text: body.slice(at) });
42
+ return parts;
43
+ };
44
+ //#endregion
45
+ export { activeMention, bodyParts, insertMention, mentionedIds };
@@ -0,0 +1,18 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useDebounced } from "../../lib/use-debounced.js";
4
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
5
+ //#region src/app/comments/use-mentionable.ts
6
+ /** The people a comment on the record may mention whose name contains `query`, asked 150 ms after it last changed; none while `query` is `undefined`. */
7
+ const useMentionable = (resource, recordKey, query) => {
8
+ const client = useClient();
9
+ const settled = useDebounced(query, 150);
10
+ return useQuery({
11
+ queryKey: keys.mentionable(resource, recordKey, settled ?? ""),
12
+ queryFn: () => client.mentionable(resource, recordKey, settled ?? ""),
13
+ enabled: settled !== void 0,
14
+ placeholderData: keepPreviousData
15
+ });
16
+ };
17
+ //#endregion
18
+ export { useMentionable };
@@ -0,0 +1,15 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
4
+ //#region src/app/comments/use-post-comment.ts
5
+ /** Posts a comment on the record, then loads its comments again so the new one shows. */
6
+ const usePostComment = (resource, recordKey) => {
7
+ const client = useClient();
8
+ const queryClient = useQueryClient();
9
+ return useMutation({
10
+ mutationFn: (input) => client.comment(resource, recordKey, input),
11
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: keys.comments(resource, recordKey) })
12
+ });
13
+ };
14
+ //#endregion
15
+ export { usePostComment };
@@ -0,0 +1,24 @@
1
+ import { keys } from "../../data/query-keys.js";
2
+ import { useClient } from "../../data/api-provider.js";
3
+ import { useInfiniteQuery } from "@tanstack/react-query";
4
+ //#region src/app/comments/use-record-comments.ts
5
+ const pageSize = 20;
6
+ /** A record's comments, oldest first: the newest page, then each earlier one `fetchNextPage` loads, at the top. */
7
+ const useRecordComments = (resource, recordKey) => {
8
+ const client = useClient();
9
+ const query = useInfiniteQuery({
10
+ queryKey: keys.comments(resource, recordKey),
11
+ initialPageParam: "",
12
+ queryFn: ({ pageParam }) => client.comments(resource, recordKey, {
13
+ pageSize,
14
+ pageToken: pageParam
15
+ }),
16
+ getNextPageParam: (last) => last.nextPageToken || void 0
17
+ });
18
+ return {
19
+ ...query,
20
+ items: query.data?.pages.flatMap((page) => page.items).reverse() ?? []
21
+ };
22
+ };
23
+ //#endregion
24
+ export { useRecordComments };
@@ -1,14 +1,14 @@
1
1
  import { useClient } from "../../data/api-provider.js";
2
- import { recordKey } from "../../live/model-helpers.js";
3
2
  import { humanize } from "../../live/naming.js";
4
- import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
5
3
  import { createBody, createSections, hasDatabaseDefault, initialDraft, isChosen, isRequired } from "./create-values.js";
4
+ import { recordKey } from "../../live/model-helpers.js";
5
+ import { Notice, useAdminMeta, usePermissions } from "../meta-gate.js";
6
6
  import { useAppAddress } from "../use-app-address.js";
7
7
  import { FieldEditor } from "../field-editor.js";
8
- import { can } from "../permissions.js";
9
- import { fetchLabels } from "../../live/relation-labels.js";
10
8
  import { fieldErrors, fieldMessage } from "../field-errors.js";
11
9
  import { useFocusFirstInvalid } from "../use-focus-first-invalid.js";
10
+ import { can } from "../permissions.js";
11
+ import { fetchLabels } from "../../live/relation-labels.js";
12
12
  import { useLiveSave } from "../use-live-save.js";
13
13
  import { CreateRecordForm } from "./create-record-form.js";
14
14
  import { useEffect, useState } from "react";
@@ -1,8 +1,8 @@
1
- import { AppAddress, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
1
+ import { AppAddress, CalendarView, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
2
2
  //#region src/app/current-address.d.ts
3
3
  /**
4
- * A list as the app shows it: every value there, the view's default where the address names none; `map` and
5
- * `savedSearch`, the saved search it was opened from, have none.
4
+ * A list as the app shows it: every value there, the view's default where the address names none; `map`, `calendar`
5
+ * and `savedSearch`, the saved search it was opened from, have none.
6
6
  */
7
7
  export type ListAddress = {
8
8
  kind: 'list';
@@ -12,6 +12,7 @@ export type ListAddress = {
12
12
  orderBy: string;
13
13
  filterLayout: FilterLayout;
14
14
  map?: MapView;
15
+ calendar?: CalendarView;
15
16
  savedSearch?: string;
16
17
  };
17
18
  /** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
@@ -56,6 +56,7 @@ const changedList = (read, change, defaults) => {
56
56
  ...differs("orderBy") && { orderBy: next.orderBy },
57
57
  ...differs("filterLayout") && { filterLayout: next.filterLayout },
58
58
  ...next.map && { map: next.map },
59
+ ...next.calendar && { calendar: next.calendar },
59
60
  ...next.savedSearch && { savedSearch: next.savedSearch }
60
61
  };
61
62
  };
@@ -1,8 +1,8 @@
1
1
  import { Problem } from "./pages/problem.js";
2
2
  import { CustomBoundary } from "./pages/custom-boundary.js";
3
- import { useProjectUi } from "./pages/project-ui.js";
4
3
  import { Section } from "../record-view/section.js";
5
4
  import { SidebarCard } from "../record-view/record-sidebar.js";
5
+ import { useProjectUi } from "./pages/project-ui.js";
6
6
  import { RecordScope } from "./pages/record-scope.js";
7
7
  import { jsx, jsxs } from "react/jsx-runtime";
8
8
  import { unmarkFieldRefs } from "@protobase/schema/internal";
@@ -1,5 +1,5 @@
1
- import { recordTitleField } from "../../live/model-helpers.js";
2
1
  import { humanize } from "../../live/naming.js";
2
+ import { recordTitleField } from "../../live/model-helpers.js";
3
3
  import { isApiError } from "@protobase/client";
4
4
  import { enumLabel, printFilter, where } from "@protobase/schema";
5
5
  //#region src/app/displays/board-columns.ts
@@ -1,5 +1,5 @@
1
- import { usePermissions } from "../meta-gate.js";
2
1
  import { boardCard, boardColumns } from "./board-columns.js";
2
+ import { usePermissions } from "../meta-gate.js";
3
3
  import { can } from "../permissions.js";
4
4
  import { RecordCard } from "./record-card.js";
5
5
  import { collision, columnCoordinates, columnId } from "./board-dnd.js";
@@ -0,0 +1,69 @@
1
+ import { segmentedClass } from "../../primitives/segmented.js";
2
+ import { formatDayRange, formatMonth } from "../../format/date.js";
3
+ import { buttonClass } from "../../primitives/button.js";
4
+ import { AppLink } from "../use-app-address.js";
5
+ import { calendarView, gridDays, stepView, todayView } from "./calendar-range.js";
6
+ import { ChevronLeft, ChevronRight } from "lucide-react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/app/displays/calendar-controls.tsx
9
+ /** The heading of the month or week a calendar shows. */
10
+ const periodText = (view) => {
11
+ const days = gridDays(view);
12
+ const ms = (day) => Date.parse(`${day}T00:00:00Z`);
13
+ return view.scale === "month" ? formatMonth(ms(view.day)) : formatDayRange(ms(days[0]), ms(days.at(-1)));
14
+ };
15
+ /**
16
+ * The month or week a calendar shows, with the way to the one before, the one holding today, the one after, and the
17
+ * month or the week: links when the calendar's view is in the address, so each opens in a new tab, else buttons.
18
+ */
19
+ const CalendarControls = ({ view, now, linkTo, onView }) => {
20
+ const control = (to, className, label, children, current) => linkTo ? /* @__PURE__ */ jsx(AppLink, {
21
+ to: linkTo(to),
22
+ className,
23
+ "aria-label": label,
24
+ "aria-current": current ? "page" : void 0,
25
+ children
26
+ }) : /* @__PURE__ */ jsx("button", {
27
+ type: "button",
28
+ className,
29
+ "aria-label": label,
30
+ "aria-pressed": current,
31
+ onClick: () => onView?.(to),
32
+ children
33
+ });
34
+ const holdsToday = view.scale === "month" && new Date(now).toISOString().startsWith(view.day.slice(0, 7));
35
+ const scaled = (to) => to === "week" && holdsToday ? todayView("week", now) : calendarView({
36
+ scale: to,
37
+ day: view.day
38
+ }, now);
39
+ const scale = (to, label) => control(scaled(to), segmentedClass.option(view.scale === to), label, label, view.scale === to);
40
+ const unit = view.scale === "month" ? "month" : "week";
41
+ const square = buttonClass({
42
+ variant: "secondary",
43
+ size: "sm"
44
+ });
45
+ return /* @__PURE__ */ jsxs("div", {
46
+ className: "flex flex-wrap items-center justify-between gap-2",
47
+ children: [/* @__PURE__ */ jsx("h2", {
48
+ className: "text-sm font-medium",
49
+ children: periodText(view)
50
+ }), /* @__PURE__ */ jsxs("div", {
51
+ className: "flex items-center gap-2",
52
+ children: [/* @__PURE__ */ jsxs("div", {
53
+ className: "flex items-center gap-1",
54
+ children: [
55
+ control(stepView(view, -1), square, `Previous ${unit}`, /* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" })),
56
+ control(todayView(view.scale, now), square, "Today", "Today"),
57
+ control(stepView(view, 1), square, `Next ${unit}`, /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5" }))
58
+ ]
59
+ }), /* @__PURE__ */ jsxs("span", {
60
+ role: "group",
61
+ "aria-label": "Calendar scale",
62
+ className: segmentedClass.group(),
63
+ children: [scale("month", "Month"), scale("week", "Week")]
64
+ })]
65
+ })]
66
+ });
67
+ };
68
+ //#endregion
69
+ export { CalendarControls, periodText };
@@ -0,0 +1,33 @@
1
+ import { cn } from "../../lib/cn.js";
2
+ import { formatWeekday, formatWeekdayDate } from "../../format/date.js";
3
+ import { dayId } from "./calendar-dnd.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { useDroppable } from "@dnd-kit/core";
6
+ //#region src/app/displays/calendar-day.tsx
7
+ /** A day of a calendar, where an event is dropped: headed by its number, today marked, its events in start order. */
8
+ const CalendarDay = ({ day, count, children, more, inMonth, today, week }) => {
9
+ const { setNodeRef, isOver } = useDroppable({ id: dayId(day) });
10
+ const ms = Date.parse(`${day}T00:00:00Z`);
11
+ return /* @__PURE__ */ jsxs("section", {
12
+ ref: setNodeRef,
13
+ "aria-label": `${formatWeekdayDate(ms)}, ${count}`,
14
+ className: cn("flex min-w-0 flex-col gap-1 bg-background p-1", week ? "min-h-28 sm:min-h-64" : "min-h-24", !inMonth && "bg-surface", isOver && "ring-2 ring-inset ring-primary/30"),
15
+ children: [
16
+ /* @__PURE__ */ jsxs("header", {
17
+ className: cn("flex items-center gap-1 px-0.5 text-xs", inMonth ? "text-foreground" : "text-muted-foreground"),
18
+ children: [week && /* @__PURE__ */ jsx("span", {
19
+ className: "text-muted-foreground",
20
+ children: formatWeekday(ms)
21
+ }), /* @__PURE__ */ jsx("span", {
22
+ "aria-current": today ? "date" : void 0,
23
+ className: cn("inline-flex size-5 items-center justify-center rounded-full tabular-nums", today && "bg-primary font-medium text-primary-foreground"),
24
+ children: Number(day.slice(8))
25
+ })]
26
+ }),
27
+ children,
28
+ more
29
+ ]
30
+ });
31
+ };
32
+ //#endregion
33
+ export { CalendarDay };