@protobase/ui 0.45.20 → 0.45.22
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 +4 -1
- package/dist/app/pages/merge-ui.d.ts +1 -1
- package/dist/app/pages/merge-ui.js +5 -3
- package/dist/app/pages/project-ui.d.ts +5 -2
- package/dist/app/shell-bell.js +12 -0
- package/dist/auth/auth-provider.js +1 -1
- package/dist/data/api-provider.d.ts +18 -0
- package/dist/data/meta-data.d.ts +2 -0
- package/dist/data/query-keys.js +6 -0
- package/dist/data/use-meta.js +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -2
- package/dist/notifications/bell/messages.js +40 -0
- package/dist/notifications/bell/notification-bell.js +91 -0
- package/dist/notifications/bell/notification-item.js +75 -0
- package/dist/notifications/bell/notification-panel.js +71 -0
- package/dist/notifications/bell/notification-target.js +28 -0
- package/dist/notifications/bell/push-control.js +41 -0
- package/dist/notifications/bell/unread-label.js +12 -0
- package/dist/notifications/bell/use-inbox.d.ts +239 -0
- package/dist/notifications/bell/use-inbox.js +91 -0
- package/dist/notifications/push/use-push-notifications.d.ts +2 -1
- package/dist/notifications/push/use-push-notifications.js +7 -2
- package/dist/notifications/push/watch-permission.js +31 -0
- package/dist/package.js +1 -1
- package/dist/plugins/plugin-ui.js +2 -1
- package/dist/primitives/button.d.ts +4 -2
- package/dist/primitives/button.js +2 -1
- package/dist/primitives/popover-menu.js +14 -8
- package/dist/shell/assets/app-D00G7iiY.js +9 -0
- package/dist/shell/assets/{index-CvTuaRIF.js → index-BOhFm-rc.js} +2 -2
- package/dist/shell/assets/index-BZXw3deq.css +2 -0
- package/dist/shell/assets/{records-map-DXlU-ssW.js → records-map-XphdZAoN.js} +2 -2
- package/dist/shell/assets/shared-protobase-ui-CM_fhVZg.js +2 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/shell-source-maps/assets/app-D00G7iiY.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-CvTuaRIF.js.map → index-BOhFm-rc.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-DXlU-ssW.js.map → records-map-XphdZAoN.js.map} +1 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-CM_fhVZg.js.map +1 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-1nDh53tv.js +0 -9
- package/dist/shell/assets/index-DVdwieyN.css +0 -2
- package/dist/shell/assets/shared-protobase-ui-CUeF-xz_.js +0 -2
- package/dist/shell-source-maps/assets/app-1nDh53tv.js.map +0 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-CUeF-xz_.js.map +0 -1
package/dist/app/app.js
CHANGED
|
@@ -49,6 +49,7 @@ import { actingUser } from "../roles/acting-user.js";
|
|
|
49
49
|
import { useActiveRole } from "../roles/use-active-role.js";
|
|
50
50
|
import { startAddress } from "./start-address.js";
|
|
51
51
|
import { ProjectLogo } from "./project-logo.js";
|
|
52
|
+
import { ShellBell } from "./shell-bell.js";
|
|
52
53
|
import { PluginGate, usePluginsOff } from "../plugins/plugin-gate.js";
|
|
53
54
|
import { PluginNotice } from "../plugins/plugin-notice.js";
|
|
54
55
|
import { RecordPage } from "./record-page.js";
|
|
@@ -114,6 +115,7 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
114
115
|
const { shell } = useProjectUi();
|
|
115
116
|
const Actions = shell?.actions;
|
|
116
117
|
const RightPanel = shell?.rightPanel;
|
|
118
|
+
const bell = meta.bell && user && !staff && /* @__PURE__ */ jsx(ShellBell, { own: shell?.bell });
|
|
117
119
|
const [assistantOpen, setAssistantOpen] = useState(false);
|
|
118
120
|
const assistantUrl = meta.assistant?.url;
|
|
119
121
|
const assistantButton = assistantUrl && /* @__PURE__ */ jsx(AssistantButton, {
|
|
@@ -254,12 +256,13 @@ const Routes = ({ workspace, sidebarMode, assistant, runtime }) => {
|
|
|
254
256
|
groups: search.groups,
|
|
255
257
|
onSelect: open
|
|
256
258
|
} : void 0,
|
|
257
|
-
actions: (Actions || reloadButton || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
259
|
+
actions: (Actions || bell || reloadButton || runtimeButton || assistantButton) && /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
258
260
|
Actions && /* @__PURE__ */ jsx(CustomBoundary, {
|
|
259
261
|
name: "shell.actions",
|
|
260
262
|
fallback: null,
|
|
261
263
|
children: /* @__PURE__ */ jsx(Actions, {})
|
|
262
264
|
}),
|
|
265
|
+
bell,
|
|
263
266
|
reloadButton,
|
|
264
267
|
runtimeButton,
|
|
265
268
|
assistantButton
|
|
@@ -3,7 +3,7 @@ import { ProjectUi } from "./project-ui.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
|
|
5
5
|
* handlers, field types, drop rules and board drops are all kept, and one name defined by two configs is an error naming both; every shell slot
|
|
6
|
-
* and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
|
|
6
|
+
* and `beforeSignIn` component is drawn, the extended configs' first, except the logo and the bell, the last config's to set one.
|
|
7
7
|
*/
|
|
8
8
|
export declare const mergeUi: (...uis: ProjectUi[]) => ProjectUi;
|
|
9
9
|
//#endregion
|
|
@@ -36,7 +36,7 @@ const around = (wrappers) => {
|
|
|
36
36
|
/**
|
|
37
37
|
* One UI config from several, each after the ones it `extends`, the last being the app's own: components, action
|
|
38
38
|
* handlers, field types, drop rules and board drops are all kept, and one name defined by two configs is an error naming both; every shell slot
|
|
39
|
-
* and `beforeSignIn` component is drawn, the extended configs' first, except the logo, the last config's to set one.
|
|
39
|
+
* and `beforeSignIn` component is drawn, the extended configs' first, except the logo and the bell, the last config's to set one.
|
|
40
40
|
*/
|
|
41
41
|
const mergeUi = (...uis) => {
|
|
42
42
|
const named = uis.flatMap((ui, index) => flatten(ui, ui.name ?? `UI config ${index + 1}`));
|
|
@@ -48,6 +48,7 @@ const mergeUi = (...uis) => {
|
|
|
48
48
|
const actionSlot = slot(named.flatMap(({ ui }) => ui.shell?.actions ? [ui.shell.actions] : []));
|
|
49
49
|
const rightPanel = slot(named.flatMap(({ ui }) => ui.shell?.rightPanel ? [ui.shell.rightPanel] : []));
|
|
50
50
|
const logo = named.findLast(({ ui }) => ui.shell?.logo)?.ui.shell?.logo;
|
|
51
|
+
const bell = named.findLast(({ ui }) => ui.shell?.bell)?.ui.shell?.bell;
|
|
51
52
|
const beforeSignIn = around(named.flatMap(({ ui }) => ui.beforeSignIn ? [ui.beforeSignIn] : []));
|
|
52
53
|
const name = uis.at(-1)?.name;
|
|
53
54
|
return {
|
|
@@ -57,10 +58,11 @@ const mergeUi = (...uis) => {
|
|
|
57
58
|
...Object.keys(fields).length > 0 && { fields },
|
|
58
59
|
...Object.keys(dropRules).length > 0 && { dropRules },
|
|
59
60
|
...Object.keys(boardDrops).length > 0 && { boardDrops },
|
|
60
|
-
...(actionSlot || rightPanel || logo) && { shell: {
|
|
61
|
+
...(actionSlot || rightPanel || logo || bell) && { shell: {
|
|
61
62
|
...actionSlot && { actions: actionSlot },
|
|
62
63
|
...rightPanel && { rightPanel },
|
|
63
|
-
...logo && { logo }
|
|
64
|
+
...logo && { logo },
|
|
65
|
+
...bell && { bell }
|
|
64
66
|
} },
|
|
65
67
|
...beforeSignIn && { beforeSignIn }
|
|
66
68
|
};
|
|
@@ -61,13 +61,16 @@ export type ProjectUi = {
|
|
|
61
61
|
/** Which columns of a board take a card and what a drop there writes, by the name a view's `drag.board` gives (see `BoardDrop`). */
|
|
62
62
|
boardDrops?: Record<string, BoardDrop>;
|
|
63
63
|
/**
|
|
64
|
-
* Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page,
|
|
65
|
-
* at the top of the sidebar in place of Protobase's (see `ShellLogoProps`)
|
|
64
|
+
* Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page, `logo`
|
|
65
|
+
* at the top of the sidebar in place of Protobase's (see `ShellLogoProps`), and `bell` in the top bar in place of the
|
|
66
|
+
* notification bell, where that would be: with notifications on and their `ui` not false, for someone signed in as
|
|
67
|
+
* themselves.
|
|
66
68
|
*/
|
|
67
69
|
shell?: {
|
|
68
70
|
actions?: ComponentType;
|
|
69
71
|
rightPanel?: ComponentType;
|
|
70
72
|
logo?: ComponentType<ShellLogoProps>;
|
|
73
|
+
bell?: ComponentType;
|
|
71
74
|
};
|
|
72
75
|
/**
|
|
73
76
|
* Drawn around the pages someone signed out sees, only for a plugin the app loads before sign-in (`beforeSignIn` in
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { CustomBoundary } from "./pages/custom-boundary.js";
|
|
2
|
+
import { NotificationBell } from "../notifications/bell/notification-bell.js";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/app/shell-bell.tsx
|
|
5
|
+
/** The top bar's notification bell, or the project's or a plugin's `shell.bell` in its place. */
|
|
6
|
+
const ShellBell = ({ own: Own }) => Own ? /* @__PURE__ */ jsx(CustomBoundary, {
|
|
7
|
+
name: "shell.bell",
|
|
8
|
+
fallback: null,
|
|
9
|
+
children: /* @__PURE__ */ jsx(Own, {})
|
|
10
|
+
}) : /* @__PURE__ */ jsx(NotificationBell, {});
|
|
11
|
+
//#endregion
|
|
12
|
+
export { ShellBell };
|
|
@@ -64,7 +64,7 @@ const AuthProvider = ({ session: given, origin, nativeBridge, children }) => {
|
|
|
64
64
|
if (answer.status !== "modified") throw new Error("The app answered /meta without its content");
|
|
65
65
|
return {
|
|
66
66
|
appName: answer.meta.appName,
|
|
67
|
-
...answer.meta.notifications && { webPushKey: answer.meta.notifications.webPushKey }
|
|
67
|
+
...answer.meta.notifications?.webPushKey && { webPushKey: answer.meta.notifications.webPushKey }
|
|
68
68
|
};
|
|
69
69
|
};
|
|
70
70
|
return pushNotifications({
|
|
@@ -61,6 +61,15 @@ export declare const useClient: () => {
|
|
|
61
61
|
register: (device: import("@protobase/client").WebDeviceRegistration) => Promise<import("@protobase/client").RegisteredDevice>;
|
|
62
62
|
remove: (id: string) => Promise<void>;
|
|
63
63
|
};
|
|
64
|
+
notifications: {
|
|
65
|
+
list: ({ cursor, limit }?: {
|
|
66
|
+
cursor?: string;
|
|
67
|
+
limit?: number;
|
|
68
|
+
}) => Promise<import("@protobase/client").InboxPage>;
|
|
69
|
+
unread: () => Promise<number>;
|
|
70
|
+
markRead: (id: string) => Promise<void>;
|
|
71
|
+
markAllRead: (upTo: string) => Promise<void>;
|
|
72
|
+
};
|
|
64
73
|
};
|
|
65
74
|
/**
|
|
66
75
|
* The client of the API that serves `resource`: a source's for a resource the app draws from one, the app's own
|
|
@@ -124,6 +133,15 @@ export declare const useResourceClient: (resource: string) => {
|
|
|
124
133
|
register: (device: import("@protobase/client").WebDeviceRegistration) => Promise<import("@protobase/client").RegisteredDevice>;
|
|
125
134
|
remove: (id: string) => Promise<void>;
|
|
126
135
|
};
|
|
136
|
+
notifications: {
|
|
137
|
+
list: ({ cursor, limit }?: {
|
|
138
|
+
cursor?: string;
|
|
139
|
+
limit?: number;
|
|
140
|
+
}) => Promise<import("@protobase/client").InboxPage>;
|
|
141
|
+
unread: () => Promise<number>;
|
|
142
|
+
markRead: (id: string) => Promise<void>;
|
|
143
|
+
markAllRead: (upTo: string) => Promise<void>;
|
|
144
|
+
};
|
|
127
145
|
};
|
|
128
146
|
export type ApiProviderProps = {
|
|
129
147
|
/** Defaults to `/api/v1` on the current origin. */
|
package/dist/data/meta-data.d.ts
CHANGED
|
@@ -46,6 +46,8 @@ export type MetaData = {
|
|
|
46
46
|
style: string;
|
|
47
47
|
layers?: MapLayerModel[];
|
|
48
48
|
};
|
|
49
|
+
/** Whether the top bar draws the notification bell: the app has notifications and their `ui` is not false (see `ShellBell`). */
|
|
50
|
+
bell?: boolean;
|
|
49
51
|
/** The language the app's screens are in for everyone, when it sets one (see `AppLocale`). */
|
|
50
52
|
locale?: Locale;
|
|
51
53
|
/** The hosts besides the app's own whose images are drawn with the page's referrer (see `FileImage`). */
|
package/dist/data/query-keys.js
CHANGED
|
@@ -156,6 +156,12 @@ const keys = {
|
|
|
156
156
|
key,
|
|
157
157
|
path
|
|
158
158
|
],
|
|
159
|
+
/** Everything of the signed-in user's notification history. */
|
|
160
|
+
inbox: ["inbox"],
|
|
161
|
+
/** How many of the user's notifications are unread. */
|
|
162
|
+
inboxUnread: ["inbox", "unread"],
|
|
163
|
+
/** The pages of the user's notification history. */
|
|
164
|
+
inboxPages: ["inbox", "pages"],
|
|
159
165
|
/** A public form, as `/-/forms/<name>` draws it. */
|
|
160
166
|
publicForm: (name) => ["public-form", name]
|
|
161
167
|
};
|
package/dist/data/use-meta.js
CHANGED
|
@@ -43,6 +43,7 @@ const useMeta = () => {
|
|
|
43
43
|
...result.meta.runtime && { runtime: result.meta.runtime },
|
|
44
44
|
...result.meta.errorReporting && { errorReporting: result.meta.errorReporting },
|
|
45
45
|
...result.meta.maps && { maps: result.meta.maps },
|
|
46
|
+
...result.meta.notifications?.bell && { bell: true },
|
|
46
47
|
...result.meta.locale && { locale: result.meta.locale },
|
|
47
48
|
...result.meta.files && { files: result.meta.files },
|
|
48
49
|
...result.meta.plugins && { plugins: result.meta.plugins },
|
package/dist/index.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { RecordLink } from "./app/displays/record-link.js";
|
|
|
27
27
|
import { useApiToken } from "./auth/use-api-token.js";
|
|
28
28
|
import { PushState } from "./notifications/push/web-push.js";
|
|
29
29
|
import { usePushNotifications } from "./notifications/push/use-push-notifications.js";
|
|
30
|
+
import { useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./notifications/bell/use-inbox.js";
|
|
30
31
|
import { SignInPrompt, SignInPromptProps } from "./auth/sign-in-prompt.js";
|
|
31
32
|
import { ResponsiveDialog, ResponsiveDialogProps } from "./primitives/responsive-dialog.js";
|
|
32
33
|
import { OtpInput, OtpInputProps } from "./primitives/otp-input.js";
|
|
@@ -68,4 +69,4 @@ import { Section } from "./record-view/section.js";
|
|
|
68
69
|
import { Form, FormProps } from "./app/create/form.js";
|
|
69
70
|
import { RecordPicker, RecordPickerProps } from "./app/record-picker.js";
|
|
70
71
|
import { Spinner } from "./primitives/spinner.js";
|
|
71
|
-
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, type BoardDrop, type BoardDropContext, type BoardDropUndo, type BoardDropWrite, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, type Drop, type DropRule, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, Markdown, type MarkdownContentProps as MarkdownProps, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type PushState, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, allowDelayMs, buttonClass, definePlugin, defineUi, diffStats, isConflict, isFramed, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUpdateRecord };
|
|
72
|
+
export { ActionCard, type ActionCardProps, type ActionContext, type ActionHandler, ActionInputError, type ActionInputProps, ApiProvider, type ApiProviderProps, App, AppLink, type AppProps, AssistantDock, type AssistantDockProps, AuthLayout, type AuthLayoutProps, type BackendData, type BackendRefresh, type BeforeSignInProps, type BoardDrop, type BoardDropContext, type BoardDropUndo, type BoardDropWrite, Button, type ButtonProps, type CardAction, type CardTone, ChatMessage, ChatThread, type ChatThreadProps, type ChoiceOption, Choices, type ChoicesProps, CodeForm, type CodeFormProps, CompactTable, type CompactTableProps, Composer, type ComposerProps, type CurrentAddress, Dialog, type DialogProps, type DiffLine, DiffView, type DiffViewProps, type Drop, type DropRule, Field, FieldList, type FieldProps, type FieldWidget, type FieldWidgetEditorProps, type FieldWidgetProps, Form, type FormProps, Input, type InputProps, type LayoutRecord, type ListAddress, type ListChange, type ListDisplay, type ListDisplayRow, LogoMark, Markdown, type MarkdownContentProps as MarkdownProps, MenuSelect, type MenuSelectProps, type MetaData, OtpInput, type OtpInputProps, PartView, type PluginInfo, type ProjectUi, type PushState, type RecordCardProps, RecordLink, RecordPicker, type RecordPickerProps, ResendCodeButton, type ResendCodeButtonProps, ResponsiveDialog, type ResponsiveDialogProps, Section, Segmented, type SegmentedOption, type SegmentedProps, type ShellLogoProps, SidePanel, type SidePanelProps, SignInPrompt, type SignInPromptProps, Spinner, type Step, StepList, type StepState, Textarea, type TextareaProps, allowDelayMs, buttonClass, definePlugin, defineUi, diffStats, isConflict, isFramed, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUnreadNotifications, useUpdateRecord };
|
package/dist/index.js
CHANGED
|
@@ -48,10 +48,11 @@ import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
|
|
|
48
48
|
import { useListDisplay } from "./app/displays/display-context.js";
|
|
49
49
|
import { MenuSelect } from "./primitives/menu-select.js";
|
|
50
50
|
import { Form } from "./app/create/form.js";
|
|
51
|
+
import { usePushNotifications } from "./notifications/push/use-push-notifications.js";
|
|
52
|
+
import { useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./notifications/bell/use-inbox.js";
|
|
51
53
|
import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
|
|
52
54
|
import { App } from "./app/app.js";
|
|
53
55
|
import { RecordLink } from "./app/displays/record-link.js";
|
|
54
|
-
import { usePushNotifications } from "./notifications/push/use-push-notifications.js";
|
|
55
56
|
import { Choices } from "./primitives/choices.js";
|
|
56
57
|
import { RecordPicker } from "./app/record-picker.js";
|
|
57
|
-
export { ActionCard, ActionInputError, ApiProvider, App, AppLink, AssistantDock, AuthLayout, Button, ChatMessage, ChatThread, Choices, CodeForm, CompactTable, Composer, Dialog, DiffView, Field, FieldList, Form, Input, LogoMark, Markdown, MenuSelect, OtpInput, PartView, RecordLink, RecordPicker, ResendCodeButton, ResponsiveDialog, Section, Segmented, SidePanel, SignInPrompt, Spinner, StepList, Textarea, allowDelayMs, buttonClass, definePlugin, defineUi, diffStats, isConflict, isFramed, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useLayoutRecord, useList, useListDisplay, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUpdateRecord };
|
|
58
|
+
export { ActionCard, ActionInputError, ApiProvider, App, AppLink, AssistantDock, AuthLayout, Button, ChatMessage, ChatThread, Choices, CodeForm, CompactTable, Composer, Dialog, DiffView, Field, FieldList, Form, Input, LogoMark, Markdown, MenuSelect, OtpInput, PartView, RecordLink, RecordPicker, ResendCodeButton, ResponsiveDialog, Section, Segmented, SidePanel, SignInPrompt, Spinner, StepList, Textarea, allowDelayMs, buttonClass, definePlugin, defineUi, diffStats, isConflict, isFramed, mergeUi, parseDiff, pluginApi, protobaseVersion, uiFromModule, useApiToken, useAppAddress, useArmedAfterFocus, useAssistant, useAssistantClient, useBackendData, useClient, useFacets, useFieldControl, useGuardedPress, useHistogram, useInbox, useLayoutRecord, useList, useListDisplay, useMarkNotificationsRead, useMeta, usePlugin, usePushNotifications, useRecord, useResourceClient, useSeries, useUnreadNotifications, useUpdateRecord };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineMessages } from "../../locale/messages.js";
|
|
2
|
+
//#region src/notifications/bell/messages.ts
|
|
3
|
+
const text = defineMessages({
|
|
4
|
+
en: {
|
|
5
|
+
notifications: "Notifications",
|
|
6
|
+
notificationsUnread: (count) => `Notifications, ${count} unread`,
|
|
7
|
+
notificationsManyUnread: "Notifications, more than 99 unread",
|
|
8
|
+
markAllRead: "Mark all as read",
|
|
9
|
+
empty: "No notifications yet",
|
|
10
|
+
showMore: "Show more",
|
|
11
|
+
loadFailed: "Your notifications could not be loaded.",
|
|
12
|
+
tryAgain: "Try again",
|
|
13
|
+
unread: "Unread",
|
|
14
|
+
opensNewTab: "(opens in a new tab)",
|
|
15
|
+
pushOff: "Get notifications on this device, even when the app is closed.",
|
|
16
|
+
turnOn: "Turn on",
|
|
17
|
+
pushOn: "Notifications are on for this device.",
|
|
18
|
+
turnOff: "Turn off",
|
|
19
|
+
pushBlocked: "Notifications are blocked on this device. To get them, allow notifications for this site in your browser or device settings."
|
|
20
|
+
},
|
|
21
|
+
nl: {
|
|
22
|
+
notifications: "Meldingen",
|
|
23
|
+
notificationsUnread: (count) => `Meldingen, ${count} ongelezen`,
|
|
24
|
+
notificationsManyUnread: "Meldingen, meer dan 99 ongelezen",
|
|
25
|
+
markAllRead: "Alles als gelezen markeren",
|
|
26
|
+
empty: "Nog geen meldingen",
|
|
27
|
+
showMore: "Meer tonen",
|
|
28
|
+
loadFailed: "Je meldingen konden niet worden geladen.",
|
|
29
|
+
tryAgain: "Opnieuw proberen",
|
|
30
|
+
unread: "Ongelezen",
|
|
31
|
+
opensNewTab: "(opent in een nieuw tabblad)",
|
|
32
|
+
pushOff: "Ontvang meldingen op dit apparaat, ook als de app gesloten is.",
|
|
33
|
+
turnOn: "Inschakelen",
|
|
34
|
+
pushOn: "Meldingen staan op dit apparaat aan.",
|
|
35
|
+
turnOff: "Uitschakelen",
|
|
36
|
+
pushBlocked: "Meldingen zijn op dit apparaat geblokkeerd. Je kunt ze voor deze site toestaan in de instellingen van je browser of apparaat."
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
//#endregion
|
|
40
|
+
export { text };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Badge } from "../../primitives/badge.js";
|
|
2
|
+
import { Button } from "../../primitives/button.js";
|
|
3
|
+
import { useRouter } from "../../app/router.js";
|
|
4
|
+
import { usePopoverMenu } from "../../primitives/popover-menu.js";
|
|
5
|
+
import { usePushNotifications } from "../push/use-push-notifications.js";
|
|
6
|
+
import { NotificationPanel } from "./notification-panel.js";
|
|
7
|
+
import { notificationTarget } from "./notification-target.js";
|
|
8
|
+
import { PushControl } from "./push-control.js";
|
|
9
|
+
import { historyRefused, useInbox, useMarkNotificationsRead, useUnreadNotifications } from "./use-inbox.js";
|
|
10
|
+
import { bellLabel, unreadBadge } from "./unread-label.js";
|
|
11
|
+
import { useState } from "react";
|
|
12
|
+
import { Bell } from "lucide-react";
|
|
13
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
import { createPortal } from "react-dom";
|
|
15
|
+
//#region src/notifications/bell/notification-bell.tsx
|
|
16
|
+
/**
|
|
17
|
+
* The top bar's bell: how many of the signed-in user's notifications are unread, and the panel it opens, their inbox,
|
|
18
|
+
* which ends with push for this device. The panel loads its notifications when it opens; opening one closes it.
|
|
19
|
+
*/
|
|
20
|
+
const NotificationBell = () => {
|
|
21
|
+
const { open, setOpen, place, button, menu } = usePopoverMenu();
|
|
22
|
+
const unread = useUnreadNotifications();
|
|
23
|
+
const inbox = useInbox({ enabled: open });
|
|
24
|
+
const { markRead, markAllRead, markingAll } = useMarkNotificationsRead();
|
|
25
|
+
const push = usePushNotifications();
|
|
26
|
+
const { navigate, basePath } = useRouter();
|
|
27
|
+
const [now, setNow] = useState(Date.now);
|
|
28
|
+
const badge = unreadBadge(unread.data);
|
|
29
|
+
const label = bellLabel(unread.data);
|
|
30
|
+
const origin = window.location.origin;
|
|
31
|
+
if (historyRefused(unread.error)) return null;
|
|
32
|
+
const toggle = () => {
|
|
33
|
+
if (!open) {
|
|
34
|
+
setNow(Date.now());
|
|
35
|
+
unread.refetch();
|
|
36
|
+
if (inbox.data) inbox.refetch();
|
|
37
|
+
}
|
|
38
|
+
setOpen(!open);
|
|
39
|
+
};
|
|
40
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(Button, {
|
|
41
|
+
ref: button,
|
|
42
|
+
size: "icon",
|
|
43
|
+
variant: "ghost",
|
|
44
|
+
"aria-label": label,
|
|
45
|
+
title: label,
|
|
46
|
+
"aria-haspopup": "dialog",
|
|
47
|
+
"aria-expanded": open,
|
|
48
|
+
onClick: toggle,
|
|
49
|
+
className: "relative max-md:size-9",
|
|
50
|
+
children: [/* @__PURE__ */ jsx(Bell, { className: "size-4 max-md:size-5" }), badge && /* @__PURE__ */ jsx(Badge, {
|
|
51
|
+
tone: "red",
|
|
52
|
+
dot: false,
|
|
53
|
+
className: "pointer-events-none absolute -top-1 -right-1.5 h-4 min-w-4 justify-center px-1 text-[10px] font-semibold max-md:top-0 max-md:right-0",
|
|
54
|
+
children: badge
|
|
55
|
+
})]
|
|
56
|
+
}), open && createPortal(/* @__PURE__ */ jsx("div", {
|
|
57
|
+
ref: menu,
|
|
58
|
+
role: "dialog",
|
|
59
|
+
"aria-label": bellLabel(void 0),
|
|
60
|
+
style: place ?? { visibility: "hidden" },
|
|
61
|
+
className: "fixed z-50 w-[min(22rem,calc(100%-0.5rem))] rounded-md border bg-background shadow-pop",
|
|
62
|
+
children: /* @__PURE__ */ jsx(NotificationPanel, {
|
|
63
|
+
items: inbox.items,
|
|
64
|
+
loading: inbox.isPending,
|
|
65
|
+
failed: inbox.isError,
|
|
66
|
+
onRetry: () => void inbox.refetch(),
|
|
67
|
+
hasMore: inbox.hasNextPage,
|
|
68
|
+
loadingMore: inbox.isFetchingNextPage,
|
|
69
|
+
onMore: () => void inbox.fetchNextPage(),
|
|
70
|
+
onMarkAllRead: markAllRead,
|
|
71
|
+
markingAll,
|
|
72
|
+
targetOf: (item) => notificationTarget(item.url, {
|
|
73
|
+
origin,
|
|
74
|
+
basePath
|
|
75
|
+
}),
|
|
76
|
+
onNavigate: (path) => navigate(path),
|
|
77
|
+
onOpen: (item) => {
|
|
78
|
+
markRead(item);
|
|
79
|
+
setOpen(false);
|
|
80
|
+
},
|
|
81
|
+
footer: /* @__PURE__ */ jsx(PushControl, {
|
|
82
|
+
state: push.state,
|
|
83
|
+
onTurnOn: () => void push.turnOn(),
|
|
84
|
+
onTurnOff: () => void push.turnOff()
|
|
85
|
+
}),
|
|
86
|
+
now
|
|
87
|
+
})
|
|
88
|
+
}), document.body)] });
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { NotificationBell };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn.js";
|
|
2
|
+
import { RouteLink } from "../../primitives/route-link.js";
|
|
3
|
+
import { toneDots } from "../../primitives/badge.js";
|
|
4
|
+
import { formatRelativeTime } from "../../format/date.js";
|
|
5
|
+
import { text } from "./messages.js";
|
|
6
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/notifications/bell/notification-item.tsx
|
|
8
|
+
const row = "flex w-full gap-2.5 px-3 py-2.5 text-left";
|
|
9
|
+
/**
|
|
10
|
+
* One notification of the panel: unread ones in bold beside a dot, its text, and how long ago it came. A notification
|
|
11
|
+
* with a url is a link to it, followed in the app for the app's own pages and opened in a new tab for another site;
|
|
12
|
+
* one without is not a link.
|
|
13
|
+
*/
|
|
14
|
+
const NotificationItem = ({ item, target, onNavigate, onOpen, now }) => {
|
|
15
|
+
const unread = item.readAt === null;
|
|
16
|
+
const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
17
|
+
className: cn("mt-1.5 size-2 shrink-0 rounded-full", unread ? toneDots.blue : "bg-transparent"),
|
|
18
|
+
children: unread && /* @__PURE__ */ jsx("span", {
|
|
19
|
+
className: "sr-only",
|
|
20
|
+
children: text("unread")
|
|
21
|
+
})
|
|
22
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
23
|
+
className: "min-w-0 flex-1",
|
|
24
|
+
children: [
|
|
25
|
+
/* @__PURE__ */ jsx("span", {
|
|
26
|
+
className: cn("block text-body text-foreground", unread && "font-semibold"),
|
|
27
|
+
children: item.title
|
|
28
|
+
}),
|
|
29
|
+
item.body && /* @__PURE__ */ jsx("span", {
|
|
30
|
+
className: "mt-0.5 line-clamp-2 block text-xs text-muted-foreground",
|
|
31
|
+
children: item.body
|
|
32
|
+
}),
|
|
33
|
+
/* @__PURE__ */ jsx("time", {
|
|
34
|
+
dateTime: item.createdAt,
|
|
35
|
+
className: "mt-0.5 block text-xs text-faint-foreground",
|
|
36
|
+
children: formatRelativeTime((now - Date.parse(item.createdAt)) / 1e3)
|
|
37
|
+
}),
|
|
38
|
+
target?.kind === "external" && /* @__PURE__ */ jsx("span", {
|
|
39
|
+
className: "sr-only",
|
|
40
|
+
children: text("opensNewTab")
|
|
41
|
+
})
|
|
42
|
+
]
|
|
43
|
+
})] });
|
|
44
|
+
const opened = () => onOpen(item);
|
|
45
|
+
const middle = (event) => {
|
|
46
|
+
if (event.button === 1) opened();
|
|
47
|
+
};
|
|
48
|
+
if (!target) return /* @__PURE__ */ jsx("div", {
|
|
49
|
+
className: row,
|
|
50
|
+
children: content
|
|
51
|
+
});
|
|
52
|
+
if (target.kind === "app") return /* @__PURE__ */ jsx(RouteLink, {
|
|
53
|
+
to: {
|
|
54
|
+
href: target.href,
|
|
55
|
+
onFollow: () => onNavigate(target.path)
|
|
56
|
+
},
|
|
57
|
+
onClick: opened,
|
|
58
|
+
onAuxClick: middle,
|
|
59
|
+
className: cn(row, "hover:bg-muted"),
|
|
60
|
+
children: content
|
|
61
|
+
});
|
|
62
|
+
return /* @__PURE__ */ jsx("a", {
|
|
63
|
+
href: target.href,
|
|
64
|
+
onClick: opened,
|
|
65
|
+
onAuxClick: middle,
|
|
66
|
+
className: cn(row, "hover:bg-muted"),
|
|
67
|
+
...target.kind === "external" && {
|
|
68
|
+
target: "_blank",
|
|
69
|
+
rel: "noopener"
|
|
70
|
+
},
|
|
71
|
+
children: content
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
export { NotificationItem };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Spinner } from "../../primitives/spinner.js";
|
|
2
|
+
import { Button } from "../../primitives/button.js";
|
|
3
|
+
import { text } from "./messages.js";
|
|
4
|
+
import { NotificationItem } from "./notification-item.js";
|
|
5
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
//#region src/notifications/bell/notification-panel.tsx
|
|
7
|
+
/**
|
|
8
|
+
* The bell's panel, the user's inbox: a heading with "Mark all as read", the newest notifications first, "Show more"
|
|
9
|
+
* for older ones, and the footer. The list scrolls inside the panel: the panel is fixed to the viewport under the bell,
|
|
10
|
+
* so the page cannot scroll it.
|
|
11
|
+
*/
|
|
12
|
+
const NotificationPanel = ({ items, loading, failed, onRetry, hasMore, loadingMore, onMore, onMarkAllRead, markingAll, targetOf, onNavigate, onOpen, footer, now }) => {
|
|
13
|
+
const newest = items[0];
|
|
14
|
+
const unread = items.some((item) => item.readAt === null);
|
|
15
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
16
|
+
className: "flex max-h-[min(32rem,calc(100dvh-var(--pb-sticky-top,3rem)-1rem))] flex-col",
|
|
17
|
+
children: [
|
|
18
|
+
/* @__PURE__ */ jsxs("div", {
|
|
19
|
+
className: "flex min-h-11 items-center gap-2 border-b px-3",
|
|
20
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
21
|
+
className: "flex-1 text-body font-semibold",
|
|
22
|
+
children: text("notifications")
|
|
23
|
+
}), newest && unread && /* @__PURE__ */ jsx(Button, {
|
|
24
|
+
size: "sm",
|
|
25
|
+
variant: "ghost",
|
|
26
|
+
loading: markingAll,
|
|
27
|
+
onClick: () => onMarkAllRead(newest),
|
|
28
|
+
children: text("markAllRead")
|
|
29
|
+
})]
|
|
30
|
+
}),
|
|
31
|
+
/* @__PURE__ */ jsxs("div", {
|
|
32
|
+
className: "min-h-0 flex-1 overflow-y-auto overscroll-contain",
|
|
33
|
+
children: [loading ? /* @__PURE__ */ jsx("div", {
|
|
34
|
+
className: "flex justify-center py-8",
|
|
35
|
+
children: /* @__PURE__ */ jsx(Spinner, { className: "size-4" })
|
|
36
|
+
}) : failed ? /* @__PURE__ */ jsxs("div", {
|
|
37
|
+
className: "flex flex-col items-center gap-2 px-3 py-6 text-center text-body text-muted-foreground",
|
|
38
|
+
children: [/* @__PURE__ */ jsx("p", { children: text("loadFailed") }), /* @__PURE__ */ jsx(Button, {
|
|
39
|
+
size: "sm",
|
|
40
|
+
onClick: onRetry,
|
|
41
|
+
children: text("tryAgain")
|
|
42
|
+
})]
|
|
43
|
+
}) : items.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
44
|
+
className: "px-3 py-8 text-center text-body text-muted-foreground",
|
|
45
|
+
children: text("empty")
|
|
46
|
+
}) : /* @__PURE__ */ jsx("ul", {
|
|
47
|
+
className: "divide-y",
|
|
48
|
+
children: items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(NotificationItem, {
|
|
49
|
+
item,
|
|
50
|
+
target: targetOf(item),
|
|
51
|
+
onNavigate,
|
|
52
|
+
onOpen,
|
|
53
|
+
now
|
|
54
|
+
}) }, item.id))
|
|
55
|
+
}), hasMore && !loading && !failed && /* @__PURE__ */ jsx("div", {
|
|
56
|
+
className: "flex justify-center border-t py-2",
|
|
57
|
+
children: /* @__PURE__ */ jsx(Button, {
|
|
58
|
+
size: "sm",
|
|
59
|
+
variant: "ghost",
|
|
60
|
+
loading: loadingMore,
|
|
61
|
+
onClick: onMore,
|
|
62
|
+
children: text("showMore")
|
|
63
|
+
})
|
|
64
|
+
})]
|
|
65
|
+
}),
|
|
66
|
+
footer
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
//#endregion
|
|
71
|
+
export { NotificationPanel };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/notifications/bell/notification-target.ts
|
|
2
|
+
const notificationTarget = (url, { origin, basePath }) => {
|
|
3
|
+
if (url === null) return void 0;
|
|
4
|
+
if (!URL.canParse(url, origin)) return void 0;
|
|
5
|
+
const target = new URL(url, origin);
|
|
6
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") return void 0;
|
|
7
|
+
if (target.origin !== origin) return {
|
|
8
|
+
kind: "external",
|
|
9
|
+
href: target.href
|
|
10
|
+
};
|
|
11
|
+
const local = `${target.pathname}${target.search}${target.hash}`;
|
|
12
|
+
if (basePath === "") return {
|
|
13
|
+
kind: "app",
|
|
14
|
+
href: local,
|
|
15
|
+
path: local
|
|
16
|
+
};
|
|
17
|
+
if (target.pathname === basePath || target.pathname.startsWith(`${basePath}/`)) return {
|
|
18
|
+
kind: "app",
|
|
19
|
+
href: local,
|
|
20
|
+
path: local.slice(basePath.length) || "/"
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
kind: "page",
|
|
24
|
+
href: local
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { notificationTarget };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Button } from "../../primitives/button.js";
|
|
2
|
+
import { text } from "./messages.js";
|
|
3
|
+
import { BellOff, BellRing } from "lucide-react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/notifications/bell/push-control.tsx
|
|
6
|
+
/**
|
|
7
|
+
* Push for this device at the foot of the panel: an offer to turn it on, that it is on with a way to turn it off, or
|
|
8
|
+
* that the browser blocks it and how to allow it. Nothing while it is not known yet, or where this device cannot get it.
|
|
9
|
+
*/
|
|
10
|
+
const PushControl = ({ state, onTurnOn, onTurnOff }) => {
|
|
11
|
+
if (state === "loading" || state === "unsupported") return null;
|
|
12
|
+
if (state === "blocked") return /* @__PURE__ */ jsxs("p", {
|
|
13
|
+
className: "flex gap-2 border-t px-3 py-2.5 text-xs text-muted-foreground",
|
|
14
|
+
children: [/* @__PURE__ */ jsx(BellOff, {
|
|
15
|
+
className: "mt-0.5 size-3.5 shrink-0",
|
|
16
|
+
"aria-hidden": true
|
|
17
|
+
}), text("pushBlocked")]
|
|
18
|
+
});
|
|
19
|
+
const on = state === "on";
|
|
20
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
21
|
+
className: "flex items-center gap-2 border-t px-3 py-2.5 text-xs text-muted-foreground",
|
|
22
|
+
children: [
|
|
23
|
+
/* @__PURE__ */ jsx(BellRing, {
|
|
24
|
+
className: "size-3.5 shrink-0",
|
|
25
|
+
"aria-hidden": true
|
|
26
|
+
}),
|
|
27
|
+
/* @__PURE__ */ jsx("span", {
|
|
28
|
+
className: "min-w-0 flex-1",
|
|
29
|
+
children: text(on ? "pushOn" : "pushOff")
|
|
30
|
+
}),
|
|
31
|
+
/* @__PURE__ */ jsx(Button, {
|
|
32
|
+
size: "sm",
|
|
33
|
+
variant: on ? "secondary" : "primary",
|
|
34
|
+
onClick: on ? onTurnOff : onTurnOn,
|
|
35
|
+
children: text(on ? "turnOff" : "turnOn")
|
|
36
|
+
})
|
|
37
|
+
]
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { PushControl };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { text } from "./messages.js";
|
|
2
|
+
//#region src/notifications/bell/unread-label.ts
|
|
3
|
+
const shownMax = 99;
|
|
4
|
+
/** The count over the bell, or none while nothing is unread. */
|
|
5
|
+
const unreadBadge = (count) => count === void 0 || count <= 0 ? void 0 : count > shownMax ? `${shownMax}+` : String(count);
|
|
6
|
+
/** The bell's name for a screen reader, with how many are unread. */
|
|
7
|
+
const bellLabel = (count) => {
|
|
8
|
+
if (count === void 0 || count <= 0) return text("notifications");
|
|
9
|
+
return count > shownMax ? text("notificationsManyUnread") : text("notificationsUnread", String(count));
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { bellLabel, unreadBadge };
|