@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
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 }
@@ -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,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
  };
@@ -0,0 +1,70 @@
1
+ import { segmentedClass } from "../../primitives/segmented.js";
2
+ import { dayIn, viewerZone } from "../../format/zone.js";
3
+ import { formatDayRange, formatMonth } from "../../format/date.js";
4
+ import { buttonClass } from "../../primitives/button.js";
5
+ import { AppLink } from "../use-app-address.js";
6
+ import { calendarView, gridDays, stepView, todayView } from "./calendar-range.js";
7
+ import { ChevronLeft, ChevronRight } from "lucide-react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/app/displays/calendar-controls.tsx
10
+ /** The heading of the month or week a calendar shows. */
11
+ const periodText = (view) => {
12
+ const days = gridDays(view);
13
+ const ms = (day) => Date.parse(`${day}T00:00:00Z`);
14
+ return view.scale === "month" ? formatMonth(ms(view.day)) : formatDayRange(ms(days[0]), ms(days.at(-1)));
15
+ };
16
+ /**
17
+ * The month or week a calendar shows, with the way to the one before, the one holding today, the one after, and the
18
+ * month or the week: links when the calendar's view is in the address, so each opens in a new tab, else buttons.
19
+ */
20
+ const CalendarControls = ({ view, now, linkTo, onView }) => {
21
+ const control = (to, className, label, children, current) => linkTo ? /* @__PURE__ */ jsx(AppLink, {
22
+ to: linkTo(to),
23
+ className,
24
+ "aria-label": label,
25
+ "aria-current": current ? "page" : void 0,
26
+ children
27
+ }) : /* @__PURE__ */ jsx("button", {
28
+ type: "button",
29
+ className,
30
+ "aria-label": label,
31
+ "aria-pressed": current,
32
+ onClick: () => onView?.(to),
33
+ children
34
+ });
35
+ const holdsToday = view.scale === "month" && dayIn(now, viewerZone()).startsWith(view.day.slice(0, 7));
36
+ const scaled = (to) => to === "week" && holdsToday ? todayView("week", now) : calendarView({
37
+ scale: to,
38
+ day: view.day
39
+ }, now);
40
+ const scale = (to, label) => control(scaled(to), segmentedClass.option(view.scale === to), label, label, view.scale === to);
41
+ const unit = view.scale === "month" ? "month" : "week";
42
+ const square = buttonClass({
43
+ variant: "secondary",
44
+ size: "sm"
45
+ });
46
+ return /* @__PURE__ */ jsxs("div", {
47
+ className: "flex flex-wrap items-center justify-between gap-2",
48
+ children: [/* @__PURE__ */ jsx("h2", {
49
+ className: "text-sm font-medium",
50
+ children: periodText(view)
51
+ }), /* @__PURE__ */ jsxs("div", {
52
+ className: "flex items-center gap-2",
53
+ children: [/* @__PURE__ */ jsxs("div", {
54
+ className: "flex items-center gap-1",
55
+ children: [
56
+ control(stepView(view, -1), square, `Previous ${unit}`, /* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" })),
57
+ control(todayView(view.scale, now), square, "Today", "Today"),
58
+ control(stepView(view, 1), square, `Next ${unit}`, /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5" }))
59
+ ]
60
+ }), /* @__PURE__ */ jsxs("span", {
61
+ role: "group",
62
+ "aria-label": "Calendar scale",
63
+ className: segmentedClass.group(),
64
+ children: [scale("month", "Month"), scale("week", "Week")]
65
+ })]
66
+ })]
67
+ });
68
+ };
69
+ //#endregion
70
+ 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 };