@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.
Files changed (47) hide show
  1. package/dist/app/app.js +4 -1
  2. package/dist/app/pages/merge-ui.d.ts +1 -1
  3. package/dist/app/pages/merge-ui.js +5 -3
  4. package/dist/app/pages/project-ui.d.ts +5 -2
  5. package/dist/app/shell-bell.js +12 -0
  6. package/dist/auth/auth-provider.js +1 -1
  7. package/dist/data/api-provider.d.ts +18 -0
  8. package/dist/data/meta-data.d.ts +2 -0
  9. package/dist/data/query-keys.js +6 -0
  10. package/dist/data/use-meta.js +1 -0
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +3 -2
  13. package/dist/notifications/bell/messages.js +40 -0
  14. package/dist/notifications/bell/notification-bell.js +91 -0
  15. package/dist/notifications/bell/notification-item.js +75 -0
  16. package/dist/notifications/bell/notification-panel.js +71 -0
  17. package/dist/notifications/bell/notification-target.js +28 -0
  18. package/dist/notifications/bell/push-control.js +41 -0
  19. package/dist/notifications/bell/unread-label.js +12 -0
  20. package/dist/notifications/bell/use-inbox.d.ts +239 -0
  21. package/dist/notifications/bell/use-inbox.js +91 -0
  22. package/dist/notifications/push/use-push-notifications.d.ts +2 -1
  23. package/dist/notifications/push/use-push-notifications.js +7 -2
  24. package/dist/notifications/push/watch-permission.js +31 -0
  25. package/dist/package.js +1 -1
  26. package/dist/plugins/plugin-ui.js +2 -1
  27. package/dist/primitives/button.d.ts +4 -2
  28. package/dist/primitives/button.js +2 -1
  29. package/dist/primitives/popover-menu.js +14 -8
  30. package/dist/shell/assets/app-D00G7iiY.js +9 -0
  31. package/dist/shell/assets/{index-CvTuaRIF.js → index-BOhFm-rc.js} +2 -2
  32. package/dist/shell/assets/index-BZXw3deq.css +2 -0
  33. package/dist/shell/assets/{records-map-DXlU-ssW.js → records-map-XphdZAoN.js} +2 -2
  34. package/dist/shell/assets/shared-protobase-ui-CM_fhVZg.js +2 -0
  35. package/dist/shell/index.html +4 -4
  36. package/dist/shell/protobase.shell.json +1 -1
  37. package/dist/shell/protobase.sw.js +2 -2
  38. package/dist/shell-source-maps/assets/app-D00G7iiY.js.map +1 -0
  39. package/dist/shell-source-maps/assets/{index-CvTuaRIF.js.map → index-BOhFm-rc.js.map} +1 -1
  40. package/dist/shell-source-maps/assets/{records-map-DXlU-ssW.js.map → records-map-XphdZAoN.js.map} +1 -1
  41. package/dist/shell-source-maps/assets/shared-protobase-ui-CM_fhVZg.js.map +1 -0
  42. package/package.json +4 -4
  43. package/dist/shell/assets/app-1nDh53tv.js +0 -9
  44. package/dist/shell/assets/index-DVdwieyN.css +0 -2
  45. package/dist/shell/assets/shared-protobase-ui-CUeF-xz_.js +0 -2
  46. package/dist/shell-source-maps/assets/app-1nDh53tv.js.map +0 -1
  47. 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, and `logo`
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. */
@@ -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`). */
@@ -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
  };
@@ -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 };