@carlonicora/nextjs-jsonapi 3.19.1 → 3.20.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.
Files changed (60) hide show
  1. package/dist/{BlockNoteEditor-7VMBZI3V.js → BlockNoteEditor-2WFPGK64.js} +19 -19
  2. package/dist/{BlockNoteEditor-7VMBZI3V.js.map → BlockNoteEditor-2WFPGK64.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-FX5RSUPA.mjs → BlockNoteEditor-CTRQ6JWE.mjs} +4 -4
  4. package/dist/{BlockNoteViewer-SVVAZ6TQ.js → BlockNoteViewer-XCGBX4ST.js} +3 -3
  5. package/dist/{BlockNoteViewer-SVVAZ6TQ.js.map → BlockNoteViewer-XCGBX4ST.js.map} +1 -1
  6. package/dist/{BlockNoteViewer-RK2RKOB7.mjs → BlockNoteViewer-ZY4ZF3HO.mjs} +2 -2
  7. package/dist/billing/index.js +401 -401
  8. package/dist/billing/index.mjs +3 -3
  9. package/dist/{chunk-GNXZNUQV.mjs → chunk-CJHBL5AN.mjs} +1711 -1482
  10. package/dist/chunk-CJHBL5AN.mjs.map +1 -0
  11. package/dist/{chunk-YCFPSXLK.mjs → chunk-CK47KSFV.mjs} +6 -1
  12. package/dist/{chunk-YCFPSXLK.mjs.map → chunk-CK47KSFV.mjs.map} +1 -1
  13. package/dist/{chunk-RT3TM342.js → chunk-IQMJAYCJ.js} +1162 -933
  14. package/dist/chunk-IQMJAYCJ.js.map +1 -0
  15. package/dist/{chunk-TBSMAL5Y.js → chunk-MWZCCOTS.js} +6 -1
  16. package/dist/chunk-MWZCCOTS.js.map +1 -0
  17. package/dist/{chunk-ODH2LYWC.js → chunk-SYQMVTIC.js} +7 -7
  18. package/dist/{chunk-ODH2LYWC.js.map → chunk-SYQMVTIC.js.map} +1 -1
  19. package/dist/{chunk-IALPADOD.mjs → chunk-U55JQ4NY.mjs} +2 -2
  20. package/dist/client/index.js +4 -4
  21. package/dist/client/index.mjs +3 -3
  22. package/dist/components/index.d.mts +24 -2
  23. package/dist/components/index.d.ts +24 -2
  24. package/dist/components/index.js +8 -4
  25. package/dist/components/index.js.map +1 -1
  26. package/dist/components/index.mjs +7 -3
  27. package/dist/contexts/index.js +4 -4
  28. package/dist/contexts/index.mjs +3 -3
  29. package/dist/core/index.d.mts +3 -0
  30. package/dist/core/index.d.ts +3 -0
  31. package/dist/core/index.js +2 -2
  32. package/dist/core/index.mjs +1 -1
  33. package/dist/features/help/index.js +37 -37
  34. package/dist/features/help/index.mjs +3 -3
  35. package/dist/features/tokenusage/index.js +101 -101
  36. package/dist/features/tokenusage/index.mjs +3 -3
  37. package/dist/index.js +3 -3
  38. package/dist/index.mjs +2 -2
  39. package/dist/server/index.js +3 -3
  40. package/dist/server/index.mjs +1 -1
  41. package/package.json +1 -1
  42. package/src/features/notification/components/containers/NotificationsListContainer.tsx +35 -18
  43. package/src/features/notification/components/filters/NotificationViewSwitcher.tsx +48 -0
  44. package/src/features/notification/components/filters/index.ts +1 -0
  45. package/src/features/notification/components/index.ts +1 -0
  46. package/src/features/notification/components/lists/NotificationsList.tsx +59 -104
  47. package/src/features/notification/components/modals/NotificationModal.tsx +97 -27
  48. package/src/features/notification/components/modals/__tests__/NotificationModal.spec.tsx +15 -4
  49. package/src/features/notification/components/notifications/Notification.tsx +19 -14
  50. package/src/features/notification/components/notifications/NotificationIcon.tsx +37 -0
  51. package/src/features/notification/components/notifications/index.ts +1 -0
  52. package/src/features/notification/data/notification.fields.ts +4 -0
  53. package/src/features/notification/hooks/useNotificationTableStructure.tsx +186 -0
  54. package/src/features/notification/notification.module.ts +2 -0
  55. package/dist/chunk-GNXZNUQV.mjs.map +0 -1
  56. package/dist/chunk-RT3TM342.js.map +0 -1
  57. package/dist/chunk-TBSMAL5Y.js.map +0 -1
  58. /package/dist/{BlockNoteEditor-FX5RSUPA.mjs.map → BlockNoteEditor-CTRQ6JWE.mjs.map} +0 -0
  59. /package/dist/{BlockNoteViewer-RK2RKOB7.mjs.map → BlockNoteViewer-ZY4ZF3HO.mjs.map} +0 -0
  60. /package/dist/{chunk-IALPADOD.mjs.map → chunk-U55JQ4NY.mjs.map} +0 -0
@@ -46,6 +46,15 @@ vi.mock("../../../../../contexts", () =>
46
46
  );
47
47
 
48
48
  vi.mock("../../../../../hooks", () => mocks.barrel({ usePageUrlGenerator: () => (p: unknown) => String(p) }));
49
+
50
+ // `Modules` is a Proxy that throws for any module the app has not registered,
51
+ // and the "view all" link reads `Modules.Notification` while rendering. Only
52
+ // that key is replaced — the rest of the barrel stays real, because the data
53
+ // layer imported alongside it still extends `AbstractApiData`.
54
+ vi.mock("../../../../../core", async () => {
55
+ const actual = await vi.importActual<Record<string, unknown>>("../../../../../core");
56
+ return { ...actual, Modules: { Notification: { name: "notifications", pageUrl: "/notifications" } } };
57
+ });
49
58
  vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
50
59
 
51
60
  vi.mock("../../../contexts/NotificationContext", () => ({
@@ -66,15 +75,17 @@ vi.mock("../../../contexts/NotificationContext", () => ({
66
75
  vi.mock("../../../../../shadcnui", () => {
67
76
  const passthrough = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
68
77
  return mocks.barrel({
69
- Card: passthrough,
70
- CardHeader: passthrough,
71
- CardTitle: passthrough,
78
+ Link: passthrough,
72
79
  Popover: passthrough,
73
80
  PopoverContent: passthrough,
81
+ PopoverHeader: passthrough,
82
+ PopoverTitle: passthrough,
74
83
  PopoverTrigger: passthrough,
75
84
  ScrollArea: passthrough,
76
- Separator: () => null,
77
85
  SidebarMenuButton: passthrough,
86
+ Tooltip: passthrough,
87
+ TooltipContent: passthrough,
88
+ TooltipTrigger: passthrough,
78
89
  });
79
90
  });
80
91
 
@@ -4,9 +4,11 @@ import { useTranslations } from "next-intl";
4
4
  import { ReactElement, useEffect, useState } from "react";
5
5
  import { usePageUrlGenerator } from "../../../../hooks";
6
6
  import { Link } from "../../../../shadcnui";
7
+ import { formatDate } from "../../../../utils";
7
8
  import { UserInterface } from "../../../user";
8
9
  import { UserAvatar } from "../../../user/components";
9
10
  import { NotificationInterface } from "../../data";
11
+ import { NotificationIcon } from "./NotificationIcon";
10
12
 
11
13
  type TaskCommentedOnProps = {
12
14
  notification: NotificationInterface;
@@ -93,34 +95,37 @@ export function NotificationMenuItem({ notification, closePopover }: TaskComment
93
95
  const data = generateNotificationData({ notification: notification, generateUrl: generateUrl });
94
96
 
95
97
  const response = (
96
- <div className={`flex w-full flex-row p-2 ${isRead ? "" : "bg-muted"} items-center`}>
97
- {data.actor ? (
98
- <div className="flex w-12 max-w-12 px-2">
99
- <UserAvatar user={data.actor} className="h-8 w-8" />
100
- </div>
101
- ) : (
102
- <div className="flex w-14 max-w-14 px-2"></div>
103
- )}
104
- <div className="flex w-full flex-col">
105
- <p className="text-sm">
98
+ /* Unread is marked with a tinted row + dot, not the muted background the
99
+ row used to carry: grey-on-grey reads as disabled, the opposite of
100
+ "needs your attention". */
101
+ <div
102
+ className={`hover:bg-accent flex w-full flex-row items-start gap-3 px-3 py-2.5 transition-colors ${isRead ? "" : "bg-primary/5"}`}
103
+ >
104
+ <NotificationIcon notificationType={notification.notificationType} />
105
+ <div className="flex min-w-0 flex-1 flex-col">
106
+ <p className="text-foreground text-sm">
106
107
  {t.rich(`notification.${notification.notificationType}.description` as any, {
107
- strong: (chunks: any) => <strong>{chunks}</strong>,
108
+ strong: (chunks: any) => <strong className="font-medium">{chunks}</strong>,
108
109
  actor: data.actor?.name ?? "",
109
110
  title: data.title,
110
111
  message: notification.message ?? "",
111
112
  })}
112
113
  </p>
113
- <div className="text-muted-foreground mt-1 w-full text-xs">
114
- {new Date(notification.createdAt).toLocaleString()}
114
+ <div className="text-muted-foreground mt-0.5 text-xs">
115
+ {formatDate(new Date(notification.createdAt), "default")}
115
116
  </div>
116
117
  </div>
118
+ {!isRead && <span className="bg-primary mt-1.5 size-2 shrink-0 rounded-full" aria-hidden />}
117
119
  </div>
118
120
  );
119
121
 
120
122
  if (!data.url) return response;
121
123
 
124
+ /* The whole row is the click target, so the package Link's text-primary
125
+ font-medium (typography role 14 — inline navigational text) would paint
126
+ every notification blue. Emphasis inside the sentence comes from weight. */
122
127
  return (
123
- <Link href={data.url} onClick={closePopover}>
128
+ <Link href={data.url} onClick={closePopover} className="text-foreground block font-normal">
124
129
  {response}
125
130
  </Link>
126
131
  );
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { AudioLinesIcon, BellIcon, LucideIcon } from "lucide-react";
4
+ import { cn } from "../../../../utils";
5
+
6
+ type IconTone = "muted" | "warning" | "destructive";
7
+
8
+ /* Every row carries a glyph in the same 32px circle, which keeps the left rail
9
+ aligned and gives the eye a faster channel than reading the sentence. Types
10
+ not listed here fall back to the bell. */
11
+ const notificationIcons: Record<string, { icon: LucideIcon; tone: IconTone }> = {
12
+ transcript_ready: { icon: AudioLinesIcon, tone: "muted" },
13
+ };
14
+
15
+ const toneClasses: Record<IconTone, string> = {
16
+ muted: "bg-muted text-muted-foreground",
17
+ warning: "bg-warning/10 text-warning",
18
+ destructive: "bg-destructive/10 text-destructive",
19
+ };
20
+
21
+ type NotificationIconProps = {
22
+ notificationType: string;
23
+ className?: string;
24
+ };
25
+
26
+ export function NotificationIcon({ notificationType, className }: NotificationIconProps) {
27
+ const { icon: Icon, tone } = notificationIcons[notificationType] ?? { icon: BellIcon, tone: "muted" as IconTone };
28
+
29
+ return (
30
+ <div
31
+ className={cn("flex size-8 shrink-0 items-center justify-center rounded-full", toneClasses[tone], className)}
32
+ aria-hidden
33
+ >
34
+ <Icon className="size-4" />
35
+ </div>
36
+ );
37
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "./Notification";
2
2
  export * from "./PushNotificationProvider";
3
+ export * from "./NotificationIcon";
@@ -3,6 +3,10 @@ export enum NotificationFields {
3
3
 
4
4
  name = "name",
5
5
 
6
+ icon = "icon",
7
+ description = "description",
8
+ actions = "actions",
9
+
6
10
  createdAt = "createdAt",
7
11
  updatedAt = "updatedAt",
8
12
  }
@@ -0,0 +1,186 @@
1
+ "use client";
2
+
3
+ import { ColumnDef } from "@tanstack/react-table";
4
+ import { ArchiveIcon } from "lucide-react";
5
+ import { useTranslations } from "next-intl";
6
+ import { useMemo } from "react";
7
+ import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
8
+ import { Button, Tooltip, TooltipContent, TooltipTrigger } from "../../../shadcnui";
9
+ import { formatDate } from "../../../utils";
10
+ import { generateNotificationData } from "../components/notifications/Notification";
11
+ import { useNotificationContext } from "../contexts/NotificationContext";
12
+ import { NotificationIcon } from "../components/notifications/NotificationIcon";
13
+ import { NotificationInterface } from "../data";
14
+ import { NotificationFields } from "../data/notification.fields";
15
+ import { NotificationService } from "../data/notification.service";
16
+
17
+ const NOTIFICATIONS_MODULE_NAME = "notifications";
18
+
19
+ /**
20
+ * Columns for the /notifications list.
21
+ *
22
+ * The row shape deliberately mirrors the notification popover row
23
+ * (`Notification.tsx` → `NotificationMenuItem`): the same `NotificationIcon` in
24
+ * a fixed first column, the same `t.rich(...)` sentence with `font-medium`
25
+ * emphasis, the same timestamp. Someone moving from the bell to the full page
26
+ * should recognise the same rows, not a different rendering of them.
27
+ *
28
+ * `context.archived` comes from `ContentListTable`'s `context` prop — the
29
+ * archive control is hidden in the archived tab, where it has nothing to do.
30
+ */
31
+ export const useNotificationTableStructure: UseTableStructureHook<NotificationInterface, NotificationFields> = (
32
+ params,
33
+ ) => {
34
+ const t = useTranslations();
35
+ const generateUrl = usePageUrlGenerator();
36
+ const { loadNotifications } = useNotificationContext();
37
+
38
+ const archived: boolean = params.context?.archived === true;
39
+
40
+ /* The shared context is refetched, not just this list's retriever: the bell
41
+ renders from that context, and the API already hides archived rows from
42
+ it. Dropping the row only here left the popover showing a notification the
43
+ page had just archived until the context's 5-minute cache expired. */
44
+ const archiveNotification = async (notification: NotificationInterface) => {
45
+ await NotificationService.archive({ id: notification.id });
46
+ params.dataRetriever?.removeElement(notification);
47
+ await loadNotifications();
48
+ };
49
+
50
+ const tableData = useMemo(() => {
51
+ return params.data.map((notification: NotificationInterface) => {
52
+ const entry: TableContent<NotificationInterface> = { jsonApiData: notification };
53
+ entry[NotificationFields.notificationId] = notification.id;
54
+ entry[NotificationFields.createdAt] = notification.createdAt;
55
+ return entry;
56
+ });
57
+ }, [params.data]);
58
+
59
+ const fieldColumnMap: Partial<Record<NotificationFields, () => any>> = {
60
+ [NotificationFields.icon]: () => ({
61
+ id: "icon",
62
+ accessorKey: "icon",
63
+ header: ``,
64
+ meta: { className: "w-20" },
65
+ cell: ({ row }: { row: TableContent<NotificationInterface> }) => {
66
+ const notification: NotificationInterface = row.original.jsonApiData;
67
+
68
+ /* `isRead` here is the value the row was FETCHED with, and it stays
69
+ that way: opening the page marks what it shows as read, so a dot
70
+ that reacted to that would blink out under the reader's eyes. It
71
+ means "was unread when you opened the page", and it leads the row so
72
+ the eye picks up what is new before reading anything else. */
73
+ return (
74
+ <div className="flex items-center gap-2">
75
+ {notification.isRead ? (
76
+ <span className="size-2 shrink-0" aria-hidden />
77
+ ) : (
78
+ <Tooltip>
79
+ <TooltipTrigger render={<span className="bg-primary size-2 shrink-0 rounded-full" />}>
80
+ <span className="sr-only">{t(`notification.unread`)}</span>
81
+ </TooltipTrigger>
82
+ <TooltipContent>{t(`notification.unread`)}</TooltipContent>
83
+ </Tooltip>
84
+ )}
85
+ <NotificationIcon notificationType={notification.notificationType} />
86
+ </div>
87
+ );
88
+ },
89
+ enableSorting: false,
90
+ enableHiding: false,
91
+ }),
92
+ [NotificationFields.description]: () => ({
93
+ id: "description",
94
+ accessorKey: "description",
95
+ header: t(`notification.description`),
96
+ cell: ({ row }: { row: TableContent<NotificationInterface> }) => {
97
+ const notification: NotificationInterface = row.original.jsonApiData;
98
+ const data = generateNotificationData({ notification: notification, generateUrl: generateUrl });
99
+
100
+ return (
101
+ <p className="text-foreground text-sm">
102
+ {t.rich(`notification.${notification.notificationType}.description` as any, {
103
+ strong: (chunks: any) => <strong className="font-medium">{chunks}</strong>,
104
+ actor: data.actor?.name ?? "",
105
+ title: data.title,
106
+ message: notification.message ?? "",
107
+ })}
108
+ </p>
109
+ );
110
+ },
111
+ enableSorting: false,
112
+ enableHiding: false,
113
+ }),
114
+ [NotificationFields.createdAt]: () => ({
115
+ id: "createdAt",
116
+ accessorKey: "createdAt",
117
+ header: t(`notification.createdAt`),
118
+ meta: { className: "w-40" },
119
+ cell: ({ row }: { row: TableContent<NotificationInterface> }) => {
120
+ const notification: NotificationInterface = row.original.jsonApiData;
121
+
122
+ /* Not `cellDate`: that renders the absolute shape, and the popover row
123
+ shows how long ago it arrived. The two must match. */
124
+ return (
125
+ <span className="text-muted-foreground text-xs">
126
+ {formatDate(new Date(notification.createdAt), "default")}
127
+ </span>
128
+ );
129
+ },
130
+ enableSorting: false,
131
+ enableHiding: false,
132
+ }),
133
+ [NotificationFields.actions]: () => ({
134
+ id: "actions",
135
+ accessorKey: "actions",
136
+ header: ``,
137
+ meta: { className: "w-16" },
138
+ cell: ({ row }: { row: TableContent<NotificationInterface> }) => {
139
+ const notification: NotificationInterface = row.original.jsonApiData;
140
+
141
+ return (
142
+ <div className="flex flex-row items-center justify-end gap-x-1">
143
+ {!archived && (
144
+ <Tooltip>
145
+ <TooltipTrigger
146
+ render={
147
+ <Button
148
+ variant={`link`}
149
+ data-testid="notification-archive-button"
150
+ onClick={(e) => {
151
+ e.preventDefault();
152
+ e.stopPropagation();
153
+ archiveNotification(notification);
154
+ }}
155
+ className="text-muted-foreground hover:text-destructive"
156
+ />
157
+ }
158
+ >
159
+ <ArchiveIcon className="h-4 w-4 cursor-pointer" />
160
+ </TooltipTrigger>
161
+ <TooltipContent>{t(`notification.buttons.archive`)}</TooltipContent>
162
+ </Tooltip>
163
+ )}
164
+ </div>
165
+ );
166
+ },
167
+ enableSorting: false,
168
+ enableHiding: false,
169
+ }),
170
+ };
171
+
172
+ const columns = useMemo(() => {
173
+ return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
174
+ TableContent<NotificationInterface>
175
+ >[];
176
+ }, [params.fields, fieldColumnMap, t, generateUrl]);
177
+
178
+ return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
179
+ };
180
+
181
+ /* Registered by module NAME, not by `Modules.Notification`: `Modules` is a
182
+ Proxy that throws for any module the app has not bootstrapped yet, and this
183
+ line runs while the module graph is still being evaluated — before
184
+ `configureJsonApi` has had a chance to register anything. The lookup in
185
+ `useTableGenerator` keys off `module.name`, so the two are equivalent. */
186
+ registerTableGenerator(NOTIFICATIONS_MODULE_NAME, useNotificationTableStructure);
@@ -1,3 +1,4 @@
1
+ import { BellIcon } from "lucide-react";
1
2
  import { ModuleFactory } from "../../permissions";
2
3
  import { Notification } from "./data/notification";
3
4
 
@@ -6,5 +7,6 @@ export const NotificationModule = (factory: ModuleFactory) =>
6
7
  pageUrl: "/notifications",
7
8
  name: "notifications",
8
9
  model: Notification,
10
+ icon: BellIcon,
9
11
  moduleId: "9259d704-c670-4e77-a3a1-a728ffc5be3d",
10
12
  });