@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.
- package/dist/app/app.js +1 -0
- package/dist/app/charts/chart-view.js +3 -1
- package/dist/app/comments/comment-body.js +13 -0
- package/dist/app/comments/comment-composer.js +149 -0
- package/dist/app/comments/comments-section.js +78 -0
- package/dist/app/comments/mention-text.js +45 -0
- package/dist/app/comments/use-mentionable.js +18 -0
- package/dist/app/comments/use-post-comment.js +15 -0
- package/dist/app/comments/use-record-comments.js +24 -0
- package/dist/app/current-address.d.ts +4 -3
- package/dist/app/current-address.js +1 -0
- package/dist/app/displays/calendar-controls.js +70 -0
- package/dist/app/displays/calendar-day.js +33 -0
- package/dist/app/displays/calendar-display.js +175 -0
- package/dist/app/displays/calendar-dnd.js +27 -0
- package/dist/app/displays/calendar-event.js +73 -0
- package/dist/app/displays/calendar-range.js +129 -0
- package/dist/app/displays/list-displays.js +7 -1
- package/dist/app/displays/use-calendar-move.js +68 -0
- package/dist/app/displays/use-calendar-rows.js +35 -0
- package/dist/app/embedded-list/embedded-list.js +25 -21
- package/dist/app/embedded-list/embedded-row-targets.js +65 -0
- package/dist/app/export/cells.js +6 -4
- package/dist/app/field-editor.js +13 -1
- package/dist/app/import/convert.js +2 -12
- package/dist/app/import/locale-formats.js +2 -1
- package/dist/app/incoming-card.js +35 -0
- package/dist/app/incoming-relations.js +36 -0
- package/dist/app/list-chart.js +3 -1
- package/dist/app/list-page.js +16 -5
- package/dist/app/pages/field-value.js +9 -1
- package/dist/app/pages/record-actions.js +34 -16
- package/dist/app/pages/use-run-action.js +8 -6
- package/dist/app/record-page.js +50 -28
- package/dist/app/related-opens.js +22 -0
- package/dist/app/related-section.js +4 -1
- package/dist/app/runtime/runtime-status.js +5 -4
- package/dist/app/search/navigation-hits.js +45 -0
- package/dist/app/search/use-global-search.js +13 -4
- package/dist/app/use-app-address.d.ts +3 -1
- package/dist/app/use-app-address.js +8 -9
- package/dist/app/use-incoming-counts.js +38 -0
- package/dist/app/use-related-records.js +3 -10
- package/dist/app-shell/app-shell.js +9 -2
- package/dist/app-shell/index.d.ts +1 -0
- package/dist/app-shell/logo.d.ts +5 -1
- package/dist/app-shell/logo.js +10 -3
- package/dist/app-shell/sidebar.js +3 -2
- package/dist/app-shell/top-bar-slot.js +11 -0
- package/dist/app-shell/top-bar.d.ts +3 -0
- package/dist/app-shell/top-bar.js +27 -19
- package/dist/auth/user-to-shell.js +3 -1
- package/dist/data/api-provider.d.ts +20 -0
- package/dist/data/query-keys.js +15 -0
- package/dist/data-grid/cells.js +8 -1
- package/dist/data-grid/grid-header.js +17 -16
- package/dist/data-grid/header-menu.js +16 -6
- package/dist/data-grid/row-menu.js +10 -40
- package/dist/format/date.js +52 -6
- package/dist/format/number-text.js +16 -0
- package/dist/format/zone.js +62 -0
- package/dist/live/columns-from-model.js +2 -0
- package/dist/live/contact-link.js +25 -0
- package/dist/live/contact-links.js +42 -0
- package/dist/live/display-value.js +14 -4
- package/dist/live/field-values.js +20 -9
- package/dist/live/filter-string.js +30 -30
- package/dist/package.js +1 -1
- package/dist/primitives/popover-menu.js +56 -0
- package/dist/primitives/segmented.js +8 -3
- package/dist/primitives/textarea.d.ts +4 -3
- package/dist/primitives/textarea.js +9 -4
- package/dist/record-view/action-menu.js +59 -0
- package/dist/record-view/record-header.js +2 -6
- package/dist/record-view/record-sidebar.js +2 -30
- package/dist/shell/assets/app-B_L-OYQJ.js +69 -0
- package/dist/shell/assets/export-worker-BoAr3vuF.js +2 -0
- package/dist/shell/assets/{index-CQc-60lQ.js → index-CRNWPM01.js} +1 -1
- package/dist/shell/assets/index-CzqIlZPP.css +2 -0
- package/dist/shell/assets/{maplibre-BvtgTvfx.js → maplibre-DZlOrjmh.js} +1 -1
- package/dist/shell/assets/{markdown-content-CJ8g5iKO.js → markdown-content-DT_18T89.js} +1 -1
- package/dist/shell/assets/{record-map-DSXYVoqB.js → record-map-Bmns3gJf.js} +1 -1
- package/dist/shell/assets/{records-map-cU_FAK7w.js → records-map-Ci3qFCDs.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-Bc0CoTXD.js → shared-protobase-ui-DPgVYrS4.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-BbVdHzEY.js +0 -69
- package/dist/shell/assets/export-worker-CVd5saLq.js +0 -2
- 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`
|
|
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 };
|