@nubitio/admin 0.10.0 → 0.11.0

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 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.10.0",
3
+ "version": "0.11.0",
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.10.0",
57
- "@nubitio/crud": "^0.10.0",
58
- "@nubitio/hydra": "^0.10.0",
59
- "@nubitio/ui": "^0.10.0"
56
+ "@nubitio/core": "^0.11.0",
57
+ "@nubitio/hydra": "^0.11.0",
58
+ "@nubitio/crud": "^0.11.0",
59
+ "@nubitio/ui": "^0.11.0"
60
60
  }
61
61
  }