@nubitio/admin 0.10.0 → 0.11.1
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/README.md +44 -0
- package/dist/index.cjs +115 -0
- package/dist/index.d.cts +39 -1
- package/dist/index.d.mts +39 -1
- package/dist/index.mjs +117 -4
- package/dist/style.css +80 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -49,6 +49,50 @@ export function App() {
|
|
|
49
49
|
- `AdminHeader` — standalone header with action slots
|
|
50
50
|
- `AdminSidebarMenu` — standalone sidebar menu
|
|
51
51
|
- `useScreenSize` / `useScreenSizeClass` — responsive breakpoint helpers
|
|
52
|
+
- `useNotifications` / `NotificationPanel` — in-app notification inbox
|
|
53
|
+
|
|
54
|
+
## Notifications
|
|
55
|
+
|
|
56
|
+
Pairs with `nubit_admin.notification.in_app.enabled` on the backend, which
|
|
57
|
+
exposes `GET /api/notifications`. Drop the panel into a header action:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import type { AdminHeaderAction } from '@nubitio/admin';
|
|
61
|
+
import { AdminShell, NotificationPanel, useNotifications } from '@nubitio/admin';
|
|
62
|
+
|
|
63
|
+
function useNotificationsAction(): AdminHeaderAction {
|
|
64
|
+
const { unreadCount } = useNotifications();
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
id: 'notifications',
|
|
68
|
+
icon: 'ph ph-bell',
|
|
69
|
+
label: 'Notifications',
|
|
70
|
+
badge: unreadCount || undefined,
|
|
71
|
+
renderPanel: () => <NotificationPanel title="Notifications" />,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function Layout({ children }: { children: React.ReactNode }) {
|
|
76
|
+
return (
|
|
77
|
+
<AdminShell menuItems={menuItems} headerActions={[useNotificationsAction()]}>
|
|
78
|
+
{children}
|
|
79
|
+
</AdminShell>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The hook and the panel share one react-query key, so calling `useNotifications()`
|
|
85
|
+
for the badge does not cause a second request.
|
|
86
|
+
|
|
87
|
+
`useNotifications({ apiUrl = '/api/notifications', staleTimeMs = 10_000 })`
|
|
88
|
+
returns `{ items, unreadCount, loading, markAsRead, refetch }` and stays live
|
|
89
|
+
over Mercure — the backend resource is `mercure: true`, so a new notification
|
|
90
|
+
invalidates the query without polling. `markAsRead(id)` issues the
|
|
91
|
+
`PATCH { read: true }` the backend expects.
|
|
92
|
+
|
|
93
|
+
`NotificationPanel` takes its strings as props (`title`, `emptyTitle`,
|
|
94
|
+
`markAllReadLabel`) — this package has no i18n of its own, so pass translated
|
|
95
|
+
values from the app.
|
|
52
96
|
|
|
53
97
|
## License
|
|
54
98
|
|
package/dist/index.cjs
CHANGED
|
@@ -740,6 +740,119 @@ function useQuotaUsage({ featureKey, collectionPath, staleTimeMs = 1e4 }) {
|
|
|
740
740
|
};
|
|
741
741
|
}
|
|
742
742
|
//#endregion
|
|
743
|
+
//#region packages/admin/notifications/useNotifications.ts
|
|
744
|
+
/** Reads a Hydra collection body (`hydra:member`) or a plain array — same shape useQuotaUsage relies on. */
|
|
745
|
+
function readCollectionItems(body) {
|
|
746
|
+
if (Array.isArray(body)) return body;
|
|
747
|
+
if (typeof body !== "object" || body === null) return [];
|
|
748
|
+
const member = body["hydra:member"] ?? body["member"];
|
|
749
|
+
return Array.isArray(member) ? member : [];
|
|
750
|
+
}
|
|
751
|
+
/** Stable empty reference so `items` keeps its identity while the query is pending. */
|
|
752
|
+
const EMPTY_ITEMS = [];
|
|
753
|
+
/** Fetches the current user's notifications and keeps them live via Mercure — see InAppNotificationChannel on the backend. */
|
|
754
|
+
function useNotifications({ apiUrl = "/api/notifications", staleTimeMs = 1e4 } = {}) {
|
|
755
|
+
const http = (0, _nubitio_core.useCoreHttpClient)();
|
|
756
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
757
|
+
const queryKey = (0, react.useMemo)(() => ["nubit-notifications", apiUrl], [apiUrl]);
|
|
758
|
+
const query = (0, _tanstack_react_query.useQuery)({
|
|
759
|
+
queryKey,
|
|
760
|
+
queryFn: async () => {
|
|
761
|
+
return readCollectionItems((await http.get(apiUrl, { headers: { Accept: "application/ld+json" } })).data);
|
|
762
|
+
},
|
|
763
|
+
staleTime: staleTimeMs
|
|
764
|
+
});
|
|
765
|
+
(0, _nubitio_core.useMercureSubscription)(apiUrl, () => void queryClient.invalidateQueries({ queryKey }));
|
|
766
|
+
const markAsReadMutation = (0, _tanstack_react_query.useMutation)({
|
|
767
|
+
mutationFn: (id) => http.patch(`${apiUrl}/${id}`, { read: true }),
|
|
768
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey })
|
|
769
|
+
});
|
|
770
|
+
const items = query.data ?? EMPTY_ITEMS;
|
|
771
|
+
const unreadCount = items.filter((item) => !item.read).length;
|
|
772
|
+
const { mutate } = markAsReadMutation;
|
|
773
|
+
const markAsRead = (0, react.useCallback)((id) => mutate(id), [mutate]);
|
|
774
|
+
return {
|
|
775
|
+
items,
|
|
776
|
+
unreadCount,
|
|
777
|
+
loading: query.isLoading,
|
|
778
|
+
markAsRead,
|
|
779
|
+
refetch: query.refetch
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
//#endregion
|
|
783
|
+
//#region packages/admin/notifications/NotificationPanel.tsx
|
|
784
|
+
function formatRelativeTime(iso) {
|
|
785
|
+
const date = new Date(iso);
|
|
786
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
787
|
+
const diffSeconds = Math.round((date.getTime() - Date.now()) / 1e3);
|
|
788
|
+
const formatter = new Intl.RelativeTimeFormat(void 0, { numeric: "auto" });
|
|
789
|
+
const thresholds = [
|
|
790
|
+
[60, "second"],
|
|
791
|
+
[60, "minute"],
|
|
792
|
+
[24, "hour"],
|
|
793
|
+
[7, "day"],
|
|
794
|
+
[4.34524, "week"],
|
|
795
|
+
[12, "month"],
|
|
796
|
+
[Number.POSITIVE_INFINITY, "year"]
|
|
797
|
+
];
|
|
798
|
+
let value = diffSeconds;
|
|
799
|
+
for (const [limit, unit] of thresholds) {
|
|
800
|
+
if (Math.abs(value) < limit) return formatter.format(Math.round(value), unit);
|
|
801
|
+
value /= limit;
|
|
802
|
+
}
|
|
803
|
+
return formatter.format(Math.round(value), "year");
|
|
804
|
+
}
|
|
805
|
+
/** Renders inside an AdminHeaderAction.renderPanel — see AdminHeader's `actions` prop. */
|
|
806
|
+
function NotificationPanel({ title = "Notifications", emptyTitle = "You're all caught up", markAllReadLabel = "Mark all as read", ...options }) {
|
|
807
|
+
const { items, unreadCount, loading, markAsRead } = useNotifications(options);
|
|
808
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
809
|
+
className: "nb-notification-panel",
|
|
810
|
+
children: [
|
|
811
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
812
|
+
className: "nb-notification-panel__header",
|
|
813
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
814
|
+
className: "nb-notification-panel__title",
|
|
815
|
+
children: title
|
|
816
|
+
}), unreadCount > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.Button, {
|
|
817
|
+
variant: "ghost",
|
|
818
|
+
size: "sm",
|
|
819
|
+
onClick: () => items.filter((item) => !item.read).forEach((item) => markAsRead(item.id)),
|
|
820
|
+
children: markAllReadLabel
|
|
821
|
+
})]
|
|
822
|
+
}),
|
|
823
|
+
!loading && items.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.EmptyState, {
|
|
824
|
+
icon: "bell-slash",
|
|
825
|
+
title: emptyTitle
|
|
826
|
+
}),
|
|
827
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
828
|
+
className: "nb-notification-panel__list",
|
|
829
|
+
children: items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
830
|
+
className: `nb-notification-panel__item${item.read ? "" : " nb-notification-panel__item--unread"}`,
|
|
831
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
832
|
+
type: "button",
|
|
833
|
+
className: "nb-notification-panel__item-button",
|
|
834
|
+
onClick: () => !item.read && markAsRead(item.id),
|
|
835
|
+
children: [
|
|
836
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
837
|
+
className: "nb-notification-panel__item-subject",
|
|
838
|
+
children: item.subject
|
|
839
|
+
}),
|
|
840
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
841
|
+
className: "nb-notification-panel__item-body",
|
|
842
|
+
children: item.body
|
|
843
|
+
}),
|
|
844
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
845
|
+
className: "nb-notification-panel__item-time",
|
|
846
|
+
children: formatRelativeTime(item.createdAt)
|
|
847
|
+
})
|
|
848
|
+
]
|
|
849
|
+
})
|
|
850
|
+
}, item.id))
|
|
851
|
+
})
|
|
852
|
+
]
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
//#endregion
|
|
743
856
|
//#region packages/admin/auth/LoginPage.tsx
|
|
744
857
|
function joinApiPath$2(apiBaseUrl, path) {
|
|
745
858
|
return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
@@ -1384,6 +1497,7 @@ exports.AdminSidebarMenu = AdminSidebarMenu;
|
|
|
1384
1497
|
exports.FeatureGate = FeatureGate;
|
|
1385
1498
|
exports.FeatureHubLayout = FeatureHubLayout;
|
|
1386
1499
|
exports.LoginPage = LoginPage;
|
|
1500
|
+
exports.NotificationPanel = NotificationPanel;
|
|
1387
1501
|
exports.NubitDevToolsPanel = NubitDevToolsPanel;
|
|
1388
1502
|
exports.PlanPanel = PlanPanel;
|
|
1389
1503
|
exports.QuotaUsageBanner = QuotaUsageBanner;
|
|
@@ -1400,6 +1514,7 @@ exports.quotaUsageAboveGrid = quotaUsageAboveGrid;
|
|
|
1400
1514
|
exports.useAppRuntime = useAppRuntime;
|
|
1401
1515
|
exports.useFeature = useFeature;
|
|
1402
1516
|
exports.useFeatureConfig = useFeatureConfig;
|
|
1517
|
+
exports.useNotifications = useNotifications;
|
|
1403
1518
|
exports.useQuotaUsage = useQuotaUsage;
|
|
1404
1519
|
exports.useRuntimeConfig = useRuntimeConfig;
|
|
1405
1520
|
exports.useScreenSize = useScreenSize;
|
package/dist/index.d.cts
CHANGED
|
@@ -304,6 +304,44 @@ declare function useQuotaUsage({
|
|
|
304
304
|
refetch: () => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
|
|
305
305
|
};
|
|
306
306
|
//#endregion
|
|
307
|
+
//#region packages/admin/notifications/useNotifications.d.ts
|
|
308
|
+
interface NotificationItem {
|
|
309
|
+
id: number | string;
|
|
310
|
+
subject: string;
|
|
311
|
+
body: string;
|
|
312
|
+
read: boolean;
|
|
313
|
+
createdAt: string;
|
|
314
|
+
}
|
|
315
|
+
interface UseNotificationsOptions {
|
|
316
|
+
apiUrl?: string;
|
|
317
|
+
staleTimeMs?: number;
|
|
318
|
+
}
|
|
319
|
+
/** Fetches the current user's notifications and keeps them live via Mercure — see InAppNotificationChannel on the backend. */
|
|
320
|
+
declare function useNotifications({
|
|
321
|
+
apiUrl,
|
|
322
|
+
staleTimeMs
|
|
323
|
+
}?: UseNotificationsOptions): {
|
|
324
|
+
items: NotificationItem[];
|
|
325
|
+
unreadCount: number;
|
|
326
|
+
loading: boolean;
|
|
327
|
+
markAsRead: (id: NotificationItem["id"]) => void;
|
|
328
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<NotificationItem[]>, Error>>;
|
|
329
|
+
};
|
|
330
|
+
//#endregion
|
|
331
|
+
//#region packages/admin/notifications/NotificationPanel.d.ts
|
|
332
|
+
interface NotificationPanelProps extends UseNotificationsOptions {
|
|
333
|
+
title?: string;
|
|
334
|
+
emptyTitle?: string;
|
|
335
|
+
markAllReadLabel?: string;
|
|
336
|
+
}
|
|
337
|
+
/** Renders inside an AdminHeaderAction.renderPanel — see AdminHeader's `actions` prop. */
|
|
338
|
+
declare function NotificationPanel({
|
|
339
|
+
title,
|
|
340
|
+
emptyTitle,
|
|
341
|
+
markAllReadLabel,
|
|
342
|
+
...options
|
|
343
|
+
}: NotificationPanelProps): React$1.JSX.Element;
|
|
344
|
+
//#endregion
|
|
307
345
|
//#region packages/admin/auth/LoginPage.d.ts
|
|
308
346
|
interface LoginPageProps {
|
|
309
347
|
onLoggedIn: () => void;
|
|
@@ -495,4 +533,4 @@ declare function hasAnyRole(required: string | string[] | undefined, roles: stri
|
|
|
495
533
|
*/
|
|
496
534
|
declare function filterMenuByRoles(items: NubitAppMenuItem[], roles: string[]): AdminMenuItem[];
|
|
497
535
|
//#endregion
|
|
498
|
-
export { AdminHeader, type AdminHeaderAction, type AdminHeaderProps, type AdminMenuItem, type AdminMenuSubItem, AdminShell, type AdminShellProps, AdminSidebarMenu, type AdminSidebarMenuProps, type AdminSidebarMenuSelectEvent, type AppProfile, type CreateNubitAppConfig, FeatureGate, type FeatureGateProps, type FeatureHubBanner, type FeatureHubDensity, FeatureHubLayout, type FeatureHubLayoutProps, type FeatureHubTab, LoginPage, type LoginPageProps, type NotificationType, type NubitApp, type NubitAppMenuContext, type NubitAppMenuItem, type NubitAppMenuSubItem, type NubitAppRoute, type NubitAppUserMenuContext, NubitDevToolsPanel, type ParsedQuotaError, type PlanDefinition, PlanPanel, type PlanPanelLabels, type PlanPanelProps, QuotaUsageBanner, type QuotaUsageBannerProps, type RegisterField, type RegisterFieldType, RegisterPage, type RegisterPageProps, type RegisterSelectOption, type RuntimeConfig, type RuntimeConfigState, type SessionContextValue, type SessionFeatureEntitlement, type SessionProfile, SessionProvider, type SessionProviderConfig, type SessionState, type SessionTenant, StaticSessionProvider, ToastHost, type ToastHostProps, type ToastItem, type UseQuotaUsageOptions, type UseRuntimeConfigOptions, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
|
|
536
|
+
export { AdminHeader, type AdminHeaderAction, type AdminHeaderProps, type AdminMenuItem, type AdminMenuSubItem, AdminShell, type AdminShellProps, AdminSidebarMenu, type AdminSidebarMenuProps, type AdminSidebarMenuSelectEvent, type AppProfile, type CreateNubitAppConfig, FeatureGate, type FeatureGateProps, type FeatureHubBanner, type FeatureHubDensity, FeatureHubLayout, type FeatureHubLayoutProps, type FeatureHubTab, LoginPage, type LoginPageProps, type NotificationItem, NotificationPanel, type NotificationPanelProps, type NotificationType, type NubitApp, type NubitAppMenuContext, type NubitAppMenuItem, type NubitAppMenuSubItem, type NubitAppRoute, type NubitAppUserMenuContext, NubitDevToolsPanel, type ParsedQuotaError, type PlanDefinition, PlanPanel, type PlanPanelLabels, type PlanPanelProps, QuotaUsageBanner, type QuotaUsageBannerProps, type RegisterField, type RegisterFieldType, RegisterPage, type RegisterPageProps, type RegisterSelectOption, type RuntimeConfig, type RuntimeConfigState, type SessionContextValue, type SessionFeatureEntitlement, type SessionProfile, SessionProvider, type SessionProviderConfig, type SessionState, type SessionTenant, StaticSessionProvider, ToastHost, type ToastHostProps, type ToastItem, type UseNotificationsOptions, type UseQuotaUsageOptions, type UseRuntimeConfigOptions, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useNotifications, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
|
package/dist/index.d.mts
CHANGED
|
@@ -304,6 +304,44 @@ declare function useQuotaUsage({
|
|
|
304
304
|
refetch: () => Promise<import("@tanstack/react-query").QueryObserverResult<number, Error>>;
|
|
305
305
|
};
|
|
306
306
|
//#endregion
|
|
307
|
+
//#region packages/admin/notifications/useNotifications.d.ts
|
|
308
|
+
interface NotificationItem {
|
|
309
|
+
id: number | string;
|
|
310
|
+
subject: string;
|
|
311
|
+
body: string;
|
|
312
|
+
read: boolean;
|
|
313
|
+
createdAt: string;
|
|
314
|
+
}
|
|
315
|
+
interface UseNotificationsOptions {
|
|
316
|
+
apiUrl?: string;
|
|
317
|
+
staleTimeMs?: number;
|
|
318
|
+
}
|
|
319
|
+
/** Fetches the current user's notifications and keeps them live via Mercure — see InAppNotificationChannel on the backend. */
|
|
320
|
+
declare function useNotifications({
|
|
321
|
+
apiUrl,
|
|
322
|
+
staleTimeMs
|
|
323
|
+
}?: UseNotificationsOptions): {
|
|
324
|
+
items: NotificationItem[];
|
|
325
|
+
unreadCount: number;
|
|
326
|
+
loading: boolean;
|
|
327
|
+
markAsRead: (id: NotificationItem["id"]) => void;
|
|
328
|
+
refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<NotificationItem[]>, Error>>;
|
|
329
|
+
};
|
|
330
|
+
//#endregion
|
|
331
|
+
//#region packages/admin/notifications/NotificationPanel.d.ts
|
|
332
|
+
interface NotificationPanelProps extends UseNotificationsOptions {
|
|
333
|
+
title?: string;
|
|
334
|
+
emptyTitle?: string;
|
|
335
|
+
markAllReadLabel?: string;
|
|
336
|
+
}
|
|
337
|
+
/** Renders inside an AdminHeaderAction.renderPanel — see AdminHeader's `actions` prop. */
|
|
338
|
+
declare function NotificationPanel({
|
|
339
|
+
title,
|
|
340
|
+
emptyTitle,
|
|
341
|
+
markAllReadLabel,
|
|
342
|
+
...options
|
|
343
|
+
}: NotificationPanelProps): React$1.JSX.Element;
|
|
344
|
+
//#endregion
|
|
307
345
|
//#region packages/admin/auth/LoginPage.d.ts
|
|
308
346
|
interface LoginPageProps {
|
|
309
347
|
onLoggedIn: () => void;
|
|
@@ -495,4 +533,4 @@ declare function hasAnyRole(required: string | string[] | undefined, roles: stri
|
|
|
495
533
|
*/
|
|
496
534
|
declare function filterMenuByRoles(items: NubitAppMenuItem[], roles: string[]): AdminMenuItem[];
|
|
497
535
|
//#endregion
|
|
498
|
-
export { AdminHeader, type AdminHeaderAction, type AdminHeaderProps, type AdminMenuItem, type AdminMenuSubItem, AdminShell, type AdminShellProps, AdminSidebarMenu, type AdminSidebarMenuProps, type AdminSidebarMenuSelectEvent, type AppProfile, type CreateNubitAppConfig, FeatureGate, type FeatureGateProps, type FeatureHubBanner, type FeatureHubDensity, FeatureHubLayout, type FeatureHubLayoutProps, type FeatureHubTab, LoginPage, type LoginPageProps, type NotificationType, type NubitApp, type NubitAppMenuContext, type NubitAppMenuItem, type NubitAppMenuSubItem, type NubitAppRoute, type NubitAppUserMenuContext, NubitDevToolsPanel, type ParsedQuotaError, type PlanDefinition, PlanPanel, type PlanPanelLabels, type PlanPanelProps, QuotaUsageBanner, type QuotaUsageBannerProps, type RegisterField, type RegisterFieldType, RegisterPage, type RegisterPageProps, type RegisterSelectOption, type RuntimeConfig, type RuntimeConfigState, type SessionContextValue, type SessionFeatureEntitlement, type SessionProfile, SessionProvider, type SessionProviderConfig, type SessionState, type SessionTenant, StaticSessionProvider, ToastHost, type ToastHostProps, type ToastItem, type UseQuotaUsageOptions, type UseRuntimeConfigOptions, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
|
|
536
|
+
export { AdminHeader, type AdminHeaderAction, type AdminHeaderProps, type AdminMenuItem, type AdminMenuSubItem, AdminShell, type AdminShellProps, AdminSidebarMenu, type AdminSidebarMenuProps, type AdminSidebarMenuSelectEvent, type AppProfile, type CreateNubitAppConfig, FeatureGate, type FeatureGateProps, type FeatureHubBanner, type FeatureHubDensity, FeatureHubLayout, type FeatureHubLayoutProps, type FeatureHubTab, LoginPage, type LoginPageProps, type NotificationItem, NotificationPanel, type NotificationPanelProps, type NotificationType, type NubitApp, type NubitAppMenuContext, type NubitAppMenuItem, type NubitAppMenuSubItem, type NubitAppRoute, type NubitAppUserMenuContext, NubitDevToolsPanel, type ParsedQuotaError, type PlanDefinition, PlanPanel, type PlanPanelLabels, type PlanPanelProps, QuotaUsageBanner, type QuotaUsageBannerProps, type RegisterField, type RegisterFieldType, RegisterPage, type RegisterPageProps, type RegisterSelectOption, type RuntimeConfig, type RuntimeConfigState, type SessionContextValue, type SessionFeatureEntitlement, type SessionProfile, SessionProvider, type SessionProviderConfig, type SessionState, type SessionTenant, StaticSessionProvider, ToastHost, type ToastHostProps, type ToastItem, type UseNotificationsOptions, type UseQuotaUsageOptions, type UseRuntimeConfigOptions, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useNotifications, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { BrowserRouter, Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
4
|
-
import { Badge, Button, Card, FeatureGate as FeatureGate$1, IconButton, TextField, ThemeProvider, ThemeSwitcher, useFloatingPanel } from "@nubitio/ui";
|
|
5
|
-
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
|
6
|
-
import { CoreConfigProvider, CoreProvider, MercureProvider, useCoreHttpClient } from "@nubitio/core";
|
|
4
|
+
import { Badge, Button, Card, EmptyState, FeatureGate as FeatureGate$1, IconButton, TextField, ThemeProvider, ThemeSwitcher, useFloatingPanel } from "@nubitio/ui";
|
|
5
|
+
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import { CoreConfigProvider, CoreProvider, MercureProvider, useCoreHttpClient, useMercureSubscription } from "@nubitio/core";
|
|
7
7
|
import { DevToolsProvider, SmartCrudRolesProvider, isDevEnvironment, useDevTools } from "@nubitio/crud";
|
|
8
8
|
import { HydraResourceSchemaProvider, HydraResourceStoreProvider, SchemaProvider } from "@nubitio/hydra";
|
|
9
9
|
//#region packages/admin/FeatureHubLayout.tsx
|
|
@@ -716,6 +716,119 @@ function useQuotaUsage({ featureKey, collectionPath, staleTimeMs = 1e4 }) {
|
|
|
716
716
|
};
|
|
717
717
|
}
|
|
718
718
|
//#endregion
|
|
719
|
+
//#region packages/admin/notifications/useNotifications.ts
|
|
720
|
+
/** Reads a Hydra collection body (`hydra:member`) or a plain array — same shape useQuotaUsage relies on. */
|
|
721
|
+
function readCollectionItems(body) {
|
|
722
|
+
if (Array.isArray(body)) return body;
|
|
723
|
+
if (typeof body !== "object" || body === null) return [];
|
|
724
|
+
const member = body["hydra:member"] ?? body["member"];
|
|
725
|
+
return Array.isArray(member) ? member : [];
|
|
726
|
+
}
|
|
727
|
+
/** Stable empty reference so `items` keeps its identity while the query is pending. */
|
|
728
|
+
const EMPTY_ITEMS = [];
|
|
729
|
+
/** Fetches the current user's notifications and keeps them live via Mercure — see InAppNotificationChannel on the backend. */
|
|
730
|
+
function useNotifications({ apiUrl = "/api/notifications", staleTimeMs = 1e4 } = {}) {
|
|
731
|
+
const http = useCoreHttpClient();
|
|
732
|
+
const queryClient = useQueryClient();
|
|
733
|
+
const queryKey = useMemo(() => ["nubit-notifications", apiUrl], [apiUrl]);
|
|
734
|
+
const query = useQuery({
|
|
735
|
+
queryKey,
|
|
736
|
+
queryFn: async () => {
|
|
737
|
+
return readCollectionItems((await http.get(apiUrl, { headers: { Accept: "application/ld+json" } })).data);
|
|
738
|
+
},
|
|
739
|
+
staleTime: staleTimeMs
|
|
740
|
+
});
|
|
741
|
+
useMercureSubscription(apiUrl, () => void queryClient.invalidateQueries({ queryKey }));
|
|
742
|
+
const markAsReadMutation = useMutation({
|
|
743
|
+
mutationFn: (id) => http.patch(`${apiUrl}/${id}`, { read: true }),
|
|
744
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey })
|
|
745
|
+
});
|
|
746
|
+
const items = query.data ?? EMPTY_ITEMS;
|
|
747
|
+
const unreadCount = items.filter((item) => !item.read).length;
|
|
748
|
+
const { mutate } = markAsReadMutation;
|
|
749
|
+
const markAsRead = useCallback((id) => mutate(id), [mutate]);
|
|
750
|
+
return {
|
|
751
|
+
items,
|
|
752
|
+
unreadCount,
|
|
753
|
+
loading: query.isLoading,
|
|
754
|
+
markAsRead,
|
|
755
|
+
refetch: query.refetch
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
//#endregion
|
|
759
|
+
//#region packages/admin/notifications/NotificationPanel.tsx
|
|
760
|
+
function formatRelativeTime(iso) {
|
|
761
|
+
const date = new Date(iso);
|
|
762
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
763
|
+
const diffSeconds = Math.round((date.getTime() - Date.now()) / 1e3);
|
|
764
|
+
const formatter = new Intl.RelativeTimeFormat(void 0, { numeric: "auto" });
|
|
765
|
+
const thresholds = [
|
|
766
|
+
[60, "second"],
|
|
767
|
+
[60, "minute"],
|
|
768
|
+
[24, "hour"],
|
|
769
|
+
[7, "day"],
|
|
770
|
+
[4.34524, "week"],
|
|
771
|
+
[12, "month"],
|
|
772
|
+
[Number.POSITIVE_INFINITY, "year"]
|
|
773
|
+
];
|
|
774
|
+
let value = diffSeconds;
|
|
775
|
+
for (const [limit, unit] of thresholds) {
|
|
776
|
+
if (Math.abs(value) < limit) return formatter.format(Math.round(value), unit);
|
|
777
|
+
value /= limit;
|
|
778
|
+
}
|
|
779
|
+
return formatter.format(Math.round(value), "year");
|
|
780
|
+
}
|
|
781
|
+
/** Renders inside an AdminHeaderAction.renderPanel — see AdminHeader's `actions` prop. */
|
|
782
|
+
function NotificationPanel({ title = "Notifications", emptyTitle = "You're all caught up", markAllReadLabel = "Mark all as read", ...options }) {
|
|
783
|
+
const { items, unreadCount, loading, markAsRead } = useNotifications(options);
|
|
784
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
785
|
+
className: "nb-notification-panel",
|
|
786
|
+
children: [
|
|
787
|
+
/* @__PURE__ */ jsxs("div", {
|
|
788
|
+
className: "nb-notification-panel__header",
|
|
789
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
790
|
+
className: "nb-notification-panel__title",
|
|
791
|
+
children: title
|
|
792
|
+
}), unreadCount > 0 && /* @__PURE__ */ jsx(Button, {
|
|
793
|
+
variant: "ghost",
|
|
794
|
+
size: "sm",
|
|
795
|
+
onClick: () => items.filter((item) => !item.read).forEach((item) => markAsRead(item.id)),
|
|
796
|
+
children: markAllReadLabel
|
|
797
|
+
})]
|
|
798
|
+
}),
|
|
799
|
+
!loading && items.length === 0 && /* @__PURE__ */ jsx(EmptyState, {
|
|
800
|
+
icon: "bell-slash",
|
|
801
|
+
title: emptyTitle
|
|
802
|
+
}),
|
|
803
|
+
/* @__PURE__ */ jsx("ul", {
|
|
804
|
+
className: "nb-notification-panel__list",
|
|
805
|
+
children: items.map((item) => /* @__PURE__ */ jsx("li", {
|
|
806
|
+
className: `nb-notification-panel__item${item.read ? "" : " nb-notification-panel__item--unread"}`,
|
|
807
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
808
|
+
type: "button",
|
|
809
|
+
className: "nb-notification-panel__item-button",
|
|
810
|
+
onClick: () => !item.read && markAsRead(item.id),
|
|
811
|
+
children: [
|
|
812
|
+
/* @__PURE__ */ jsx("span", {
|
|
813
|
+
className: "nb-notification-panel__item-subject",
|
|
814
|
+
children: item.subject
|
|
815
|
+
}),
|
|
816
|
+
/* @__PURE__ */ jsx("span", {
|
|
817
|
+
className: "nb-notification-panel__item-body",
|
|
818
|
+
children: item.body
|
|
819
|
+
}),
|
|
820
|
+
/* @__PURE__ */ jsx("span", {
|
|
821
|
+
className: "nb-notification-panel__item-time",
|
|
822
|
+
children: formatRelativeTime(item.createdAt)
|
|
823
|
+
})
|
|
824
|
+
]
|
|
825
|
+
})
|
|
826
|
+
}, item.id))
|
|
827
|
+
})
|
|
828
|
+
]
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
//#endregion
|
|
719
832
|
//#region packages/admin/auth/LoginPage.tsx
|
|
720
833
|
function joinApiPath$2(apiBaseUrl, path) {
|
|
721
834
|
return `${apiBaseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
@@ -1354,4 +1467,4 @@ function createNubitApp(config) {
|
|
|
1354
1467
|
return { App };
|
|
1355
1468
|
}
|
|
1356
1469
|
//#endregion
|
|
1357
|
-
export { AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, FeatureHubLayout, LoginPage, NubitDevToolsPanel, PlanPanel, QuotaUsageBanner, RegisterPage, SessionProvider, StaticSessionProvider, ToastHost, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
|
|
1470
|
+
export { AdminHeader, AdminShell, AdminSidebarMenu, FeatureGate, FeatureHubLayout, LoginPage, NotificationPanel, NubitDevToolsPanel, PlanPanel, QuotaUsageBanner, RegisterPage, SessionProvider, StaticSessionProvider, ToastHost, createNubitApp, filterMenuByRoles, hasAnyRole, parseQuotaError, quotaErrorToastMessage, quotaUsageAboveGrid, useAppRuntime, useFeature, useFeatureConfig, useNotifications, useQuotaUsage, useRuntimeConfig, useScreenSize, useScreenSizeClass, useSession };
|
package/dist/style.css
CHANGED
|
@@ -829,6 +829,86 @@ html[data-density=comfortable] {
|
|
|
829
829
|
content: "✓ ";
|
|
830
830
|
font-weight: 700;
|
|
831
831
|
}
|
|
832
|
+
.nb-notification-panel {
|
|
833
|
+
display: flex;
|
|
834
|
+
flex-direction: column;
|
|
835
|
+
max-height: 420px;
|
|
836
|
+
width: 320px;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
.nb-notification-panel__header {
|
|
840
|
+
align-items: center;
|
|
841
|
+
border-bottom: 1px solid var(--border-color);
|
|
842
|
+
display: flex;
|
|
843
|
+
justify-content: space-between;
|
|
844
|
+
padding: var(--space-3);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.nb-notification-panel__title {
|
|
848
|
+
font-size: var(--font-size-sm);
|
|
849
|
+
font-weight: var(--font-weight-semibold);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.nb-notification-panel__list {
|
|
853
|
+
list-style: none;
|
|
854
|
+
margin: 0;
|
|
855
|
+
overflow-y: auto;
|
|
856
|
+
padding: 0;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
.nb-notification-panel__item-button {
|
|
860
|
+
background: none;
|
|
861
|
+
border: 0;
|
|
862
|
+
border-bottom: 1px solid var(--border-color);
|
|
863
|
+
cursor: pointer;
|
|
864
|
+
display: flex;
|
|
865
|
+
flex-direction: column;
|
|
866
|
+
gap: 2px;
|
|
867
|
+
padding: var(--space-3);
|
|
868
|
+
text-align: left;
|
|
869
|
+
width: 100%;
|
|
870
|
+
}
|
|
871
|
+
.nb-notification-panel__item-button:hover {
|
|
872
|
+
background: var(--surface-2);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.nb-notification-panel__item:last-child .nb-notification-panel__item-button {
|
|
876
|
+
border-bottom: 0;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
.nb-notification-panel__item--unread .nb-notification-panel__item-button {
|
|
880
|
+
position: relative;
|
|
881
|
+
}
|
|
882
|
+
.nb-notification-panel__item--unread .nb-notification-panel__item-button::before {
|
|
883
|
+
background: var(--accent-color);
|
|
884
|
+
border-radius: 50%;
|
|
885
|
+
content: "";
|
|
886
|
+
height: 6px;
|
|
887
|
+
left: var(--space-2);
|
|
888
|
+
position: absolute;
|
|
889
|
+
top: calc(var(--space-3) + 4px);
|
|
890
|
+
width: 6px;
|
|
891
|
+
}
|
|
892
|
+
.nb-notification-panel__item--unread .nb-notification-panel__item-button .nb-notification-panel__item-subject,
|
|
893
|
+
.nb-notification-panel__item--unread .nb-notification-panel__item-button .nb-notification-panel__item-body,
|
|
894
|
+
.nb-notification-panel__item--unread .nb-notification-panel__item-button .nb-notification-panel__item-time {
|
|
895
|
+
padding-left: var(--space-3);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
.nb-notification-panel__item-subject {
|
|
899
|
+
font-size: var(--font-size-sm);
|
|
900
|
+
font-weight: var(--font-weight-medium);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
.nb-notification-panel__item-body {
|
|
904
|
+
color: var(--text-secondary);
|
|
905
|
+
font-size: var(--font-size-sm);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
.nb-notification-panel__item-time {
|
|
909
|
+
color: var(--text-secondary);
|
|
910
|
+
font-size: var(--font-size-xxs);
|
|
911
|
+
}
|
|
832
912
|
.nb-toast-host {
|
|
833
913
|
position: fixed;
|
|
834
914
|
right: 1rem;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nubitio/admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Admin shell layout components: responsive sidebar, header, and screen size utilities for Nubit apps.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"react": "^18.2.0 || ^19.0.0",
|
|
54
54
|
"react-dom": "^18.2.0 || ^19.0.0",
|
|
55
55
|
"react-router-dom": "^6.0.0",
|
|
56
|
-
"@nubitio/core": "^0.
|
|
57
|
-
"@nubitio/crud": "^0.
|
|
58
|
-
"@nubitio/hydra": "^0.
|
|
59
|
-
"@nubitio/ui": "^0.
|
|
56
|
+
"@nubitio/core": "^0.11.1",
|
|
57
|
+
"@nubitio/crud": "^0.11.1",
|
|
58
|
+
"@nubitio/hydra": "^0.11.1",
|
|
59
|
+
"@nubitio/ui": "^0.11.1"
|
|
60
60
|
}
|
|
61
61
|
}
|