@carlonicora/nextjs-jsonapi 3.12.0 → 3.13.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 (143) hide show
  1. package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
  2. package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
  3. package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
  4. package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
  5. package/dist/{BlockNoteEditor-6G27S6P3.js → BlockNoteEditor-CFVSHS46.js} +19 -19
  6. package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-CR5KH4AI.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
  8. package/dist/{BlockNoteViewer-5MITJ7AD.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
  9. package/dist/{BlockNoteViewer-5MITJ7AD.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
  10. package/dist/{BlockNoteViewer-NJKFUNXP.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
  11. package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
  12. package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
  13. package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
  14. package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
  15. package/dist/billing/index.js +401 -401
  16. package/dist/billing/index.mjs +3 -3
  17. package/dist/{chunk-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
  18. package/dist/chunk-IB7DTXWG.js.map +1 -0
  19. package/dist/{chunk-OKGGYCO5.js → chunk-LVE3BJ6K.js} +2088 -1134
  20. package/dist/chunk-LVE3BJ6K.js.map +1 -0
  21. package/dist/{chunk-Q2BREXH3.mjs → chunk-OSFYCSTW.mjs} +724 -7
  22. package/dist/chunk-OSFYCSTW.mjs.map +1 -0
  23. package/dist/{chunk-JEGL7KHK.mjs → chunk-UQXE3SME.mjs} +2081 -1127
  24. package/dist/chunk-UQXE3SME.mjs.map +1 -0
  25. package/dist/{chunk-HVNEWDRF.mjs → chunk-VZKJIAND.mjs} +2 -2
  26. package/dist/{chunk-4ETVCRQD.js → chunk-XH4IURUD.js} +7 -7
  27. package/dist/{chunk-4ETVCRQD.js.map → chunk-XH4IURUD.js.map} +1 -1
  28. package/dist/client/index.d.mts +2 -2
  29. package/dist/client/index.d.ts +2 -2
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.mjs +3 -3
  32. package/dist/components/index.d.mts +258 -7
  33. package/dist/components/index.d.ts +258 -7
  34. package/dist/components/index.js +30 -4
  35. package/dist/components/index.js.map +1 -1
  36. package/dist/components/index.mjs +29 -3
  37. package/dist/contexts/index.d.mts +17 -5
  38. package/dist/contexts/index.d.ts +17 -5
  39. package/dist/contexts/index.js +4 -4
  40. package/dist/contexts/index.mjs +3 -3
  41. package/dist/core/index.d.mts +491 -11
  42. package/dist/core/index.d.ts +491 -11
  43. package/dist/core/index.js +38 -2
  44. package/dist/core/index.js.map +1 -1
  45. package/dist/core/index.mjs +37 -1
  46. package/dist/features/help/index.js +37 -37
  47. package/dist/features/help/index.mjs +3 -3
  48. package/dist/features/tokenusage/index.js +101 -101
  49. package/dist/features/tokenusage/index.mjs +3 -3
  50. package/dist/index.d.mts +7 -7
  51. package/dist/index.d.ts +7 -7
  52. package/dist/index.js +39 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +38 -2
  55. package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
  56. package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
  57. package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
  58. package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
  59. package/dist/server/index.d.mts +3 -3
  60. package/dist/server/index.d.ts +3 -3
  61. package/dist/server/index.js +3 -3
  62. package/dist/server/index.mjs +1 -1
  63. package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
  64. package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
  65. package/package.json +1 -1
  66. package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
  67. package/src/components/index.ts +1 -0
  68. package/src/core/index.ts +5 -0
  69. package/src/core/registry/ModuleRegistry.ts +4 -0
  70. package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
  71. package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
  72. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
  73. package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
  74. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
  75. package/src/features/assistant/data/Assistant.ts +2 -0
  76. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  77. package/src/features/assistant/data/AssistantService.ts +4 -0
  78. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
  79. package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
  80. package/src/features/content/data/content.interface.ts +1 -1
  81. package/src/features/handbook/HandbookPageModule.ts +41 -0
  82. package/src/features/handbook/HandbookSectionModule.ts +35 -0
  83. package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
  84. package/src/features/handbook/HandbookThreadModule.ts +29 -0
  85. package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
  86. package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
  87. package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
  88. package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
  89. package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
  90. package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
  91. package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
  92. package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
  93. package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
  94. package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
  95. package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
  96. package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
  97. package/src/features/handbook/components/index.ts +12 -0
  98. package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
  99. package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
  100. package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
  101. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
  102. package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
  103. package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
  104. package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
  105. package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
  106. package/src/features/handbook/data/HandbookPage.ts +99 -0
  107. package/src/features/handbook/data/HandbookPageFields.ts +31 -0
  108. package/src/features/handbook/data/HandbookPageInput.ts +8 -0
  109. package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
  110. package/src/features/handbook/data/HandbookPageService.ts +60 -0
  111. package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
  112. package/src/features/handbook/data/HandbookSection.ts +73 -0
  113. package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
  114. package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
  115. package/src/features/handbook/data/HandbookSectionService.ts +26 -0
  116. package/src/features/handbook/data/HandbookThread.ts +114 -0
  117. package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
  118. package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
  119. package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
  120. package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
  121. package/src/features/handbook/data/HandbookThreadService.ts +86 -0
  122. package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
  123. package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
  124. package/src/features/handbook/data/handbookDisplay.ts +55 -0
  125. package/src/features/handbook/data/index.ts +16 -0
  126. package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
  127. package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
  128. package/src/features/handbook/i18n-keys.ts +46 -0
  129. package/src/features/handbook/index.ts +7 -0
  130. package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
  131. package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
  132. package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
  133. package/src/features/how-to/components/index.ts +1 -0
  134. package/src/features/how-to/components/lists/HowToList.tsx +5 -1
  135. package/src/features/how-to/data/HowTo.spec.ts +61 -1
  136. package/src/features/how-to/data/HowToService.ts +13 -0
  137. package/dist/chunk-JEGL7KHK.mjs.map +0 -1
  138. package/dist/chunk-L7DZSTFU.js.map +0 -1
  139. package/dist/chunk-OKGGYCO5.js.map +0 -1
  140. package/dist/chunk-Q2BREXH3.mjs.map +0 -1
  141. /package/dist/{BlockNoteEditor-CR5KH4AI.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
  142. /package/dist/{BlockNoteViewer-NJKFUNXP.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
  143. /package/dist/{chunk-HVNEWDRF.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
@@ -0,0 +1,247 @@
1
+ import { render, screen, within } from "@testing-library/react";
2
+ import { ReactNode } from "react";
3
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
6
+ import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
+ import { configureI18n } from "../../../../../i18n";
8
+ import { HandbookPageService } from "../../../data/HandbookPageService";
9
+ import { HandbookSectionService } from "../../../data/HandbookSectionService";
10
+ import HandbookContents from "../HandbookContents";
11
+
12
+ // `vi.mock` factories are hoisted above every module-scope const, so the rows
13
+ // come from `vi.hoisted()` rather than from a plain const.
14
+ const { pages, sections } = vi.hoisted(() => ({
15
+ pages: [
16
+ {
17
+ id: "p1",
18
+ title: "Reading order",
19
+ section: "00-start-here",
20
+ order: "00-start-here/reading-order.md",
21
+ path: "00-start-here/reading-order.md",
22
+ summary: "The three paths.",
23
+ aiStatus: "completed",
24
+ wordCount: 10,
25
+ },
26
+ {
27
+ id: "p2",
28
+ title: "Testing the API",
29
+ section: "03-backend",
30
+ order: "03-backend/testing.md",
31
+ path: "03-backend/testing.md",
32
+ summary: undefined,
33
+ aiStatus: "failed",
34
+ wordCount: 20,
35
+ },
36
+ {
37
+ id: "p3",
38
+ title: "Frontend models",
39
+ section: "02-framework",
40
+ order: "02-framework/rulebook/frontend/01-models.md",
41
+ path: "02-framework/rulebook/frontend/01-models.md",
42
+ summary: "AbstractApiData.",
43
+ aiStatus: "completed",
44
+ wordCount: 30,
45
+ },
46
+ ],
47
+ sections: [
48
+ { id: "s1", key: "00-start-here", title: "Start here", summary: "Get the stack running.", order: "00-start-here" },
49
+ { id: "s2", key: "02-framework", title: "Framework", summary: undefined, order: "02-framework" },
50
+ { id: "s3", key: "03-backend", title: "Backend", summary: "The API.", order: "03-backend" },
51
+ ],
52
+ }));
53
+
54
+ vi.mock("../../../data/HandbookPageService", () => ({
55
+ HandbookPageService: { findMany: vi.fn(), sync: vi.fn() },
56
+ }));
57
+
58
+ vi.mock("../../../data/HandbookSectionService", () => ({
59
+ HandbookSectionService: { findMany: vi.fn() },
60
+ }));
61
+
62
+ // The sheet is the ask surface's (Task I). It is not under test here, and it
63
+ // needs providers this spec has no reason to mount.
64
+ vi.mock("../../containers/HandbookAskSheet", () => ({
65
+ HandbookAskSheet: () => <div data-testid="handbook-ask-sheet" />,
66
+ }));
67
+
68
+ // The suite-wide next-intl stub returns the key and drops the values, which
69
+ // would hide the two numbers the status line exists to show. This one keeps
70
+ // them, so `{count} pagine · sincronizzato {date}` is still asserted on the
71
+ // numbers rather than on a translated string this package does not own.
72
+ vi.mock("next-intl", () => {
73
+ const makeT = () => {
74
+ const t = (key: string, values?: Record<string, unknown>) =>
75
+ values ? `${key} ${Object.values(values).join(" ")}`.trimEnd() : key;
76
+ (t as any).has = () => true;
77
+ return t;
78
+ };
79
+ return {
80
+ useTranslations: () => makeT(),
81
+ useLocale: () => "en",
82
+ useNow: () => new Date(),
83
+ useTimeZone: () => "UTC",
84
+ useFormatter: () => ({
85
+ dateTime: () => "sync-date",
86
+ number: (value: unknown) => String(value),
87
+ relativeTime: () => "",
88
+ }),
89
+ };
90
+ });
91
+
92
+ // The header is the provider's job (Task F). Stubbed as
93
+ // HandbookPageContainer.spec.tsx stubs it, except that this stub renders
94
+ // `functions`: the ask launcher, Sincronizza and the status line are handed to
95
+ // the header through that prop, so dropping it would delete half of this
96
+ // component's output from the test.
97
+ vi.mock("../../../contexts/HandbookContext", () => ({
98
+ HandbookProvider: ({ children, functions }: { children?: ReactNode; functions?: ReactNode }) => (
99
+ <>
100
+ <div data-testid="page-functions">{functions}</div>
101
+ {children}
102
+ </>
103
+ ),
104
+ useHandbookContext: () => ({}),
105
+ }));
106
+
107
+ // RoundPageContainer is page chrome — header, details panel, url rewriting.
108
+ // Stubbed exactly as HandbookAskContainer.spec.tsx stubs it.
109
+ vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
110
+ RoundPageContainer: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
111
+ }));
112
+
113
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
114
+ try {
115
+ ModuleRegistry.get(key as any);
116
+ } catch {
117
+ ModuleRegistry.register(key, module);
118
+ }
119
+ };
120
+
121
+ beforeAll(() => {
122
+ registerIfAbsent("HandbookPage", {
123
+ name: "handbookpages",
124
+ pageUrl: "/administration/handbook",
125
+ } as any);
126
+
127
+ // The package `Link` resolves the app's next-intl Link at runtime; without a
128
+ // configured i18n it throws rather than rendering an anchor.
129
+ configureI18n({
130
+ useRouter: () => ({
131
+ push: () => {},
132
+ replace: () => {},
133
+ back: () => {},
134
+ forward: () => {},
135
+ refresh: () => {},
136
+ prefetch: () => {},
137
+ }),
138
+ useTranslations: () => (key: string) => key,
139
+ usePathname: () => "/administration/handbook",
140
+ Link: ({ href, children, ...props }: any) => (
141
+ <a href={href} {...props}>
142
+ {children}
143
+ </a>
144
+ ),
145
+ });
146
+ });
147
+
148
+ describe("HandbookContents", () => {
149
+ beforeEach(() => {
150
+ vi.mocked(HandbookPageService.findMany)
151
+ .mockReset()
152
+ .mockResolvedValue(pages as any);
153
+ vi.mocked(HandbookSectionService.findMany)
154
+ .mockReset()
155
+ .mockResolvedValue(sections as any);
156
+ });
157
+
158
+ it("renders sections in order with their blurbs", async () => {
159
+ render(<HandbookContents />);
160
+ const headings = await screen.findAllByRole("heading", { level: 2 });
161
+ expect(headings.map((h) => h.textContent)).toEqual(["Start here", "Framework", "Backend"]);
162
+ expect(screen.getByText("Get the stack running.")).toBeInTheDocument();
163
+ });
164
+
165
+ it("renders a page summary when it has one and nothing when it does not", async () => {
166
+ render(<HandbookContents />);
167
+ expect(await screen.findByText("The three paths.")).toBeInTheDocument();
168
+ const row = screen.getByRole("link", { name: /Testing the API/ });
169
+ expect(within(row).queryByText("—")).not.toBeInTheDocument();
170
+ expect(row.textContent).toBe("Testing the API");
171
+ });
172
+
173
+ it("groups a nested page under its intermediate directory", async () => {
174
+ render(<HandbookContents />);
175
+ expect(await screen.findByText("rulebook / frontend")).toBeInTheDocument();
176
+ });
177
+
178
+ it("counts failures in the status line", async () => {
179
+ render(<HandbookContents />);
180
+ expect(await screen.findByText(/1/)).toBeInTheDocument();
181
+ });
182
+
183
+ it("renders no table", async () => {
184
+ render(<HandbookContents />);
185
+ await screen.findAllByRole("heading", { level: 2 });
186
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
187
+ });
188
+
189
+ /**
190
+ * The manual is INDEXED in English and SHOWN translated: every string on this
191
+ * surface prefers the translation that travels beside the English original,
192
+ * and falls back to the original when the ingest matched none.
193
+ *
194
+ * The fixtures are the suite's own, with a translation added to exactly one
195
+ * page and one section — so the same render proves both halves of the rule.
196
+ */
197
+ describe("display translation", () => {
198
+ const translatedPages = pages.map((page) =>
199
+ page.id === "p1" ? { ...page, displayTitle: "Ordine di lettura", displaySummary: "I tre percorsi." } : page,
200
+ );
201
+ const translatedSections = sections.map((section) =>
202
+ section.key === "00-start-here"
203
+ ? { ...section, displayTitle: "Da qui si parte", displaySummary: "Avvia lo stack." }
204
+ : section,
205
+ );
206
+
207
+ beforeEach(() => {
208
+ vi.mocked(HandbookPageService.findMany).mockResolvedValue(translatedPages as any);
209
+ vi.mocked(HandbookSectionService.findMany).mockResolvedValue(translatedSections as any);
210
+ });
211
+
212
+ it("renders a translated section heading and blurb, and the English ones where there is no translation", async () => {
213
+ render(<HandbookContents />);
214
+ const headings = await screen.findAllByRole("heading", { level: 2 });
215
+ expect(headings.map((h) => h.textContent)).toEqual(["Da qui si parte", "Framework", "Backend"]);
216
+ expect(screen.getByText("Avvia lo stack.")).toBeInTheDocument();
217
+ expect(screen.queryByText("Get the stack running.")).not.toBeInTheDocument();
218
+ // The untranslated section still reads in English.
219
+ expect(screen.getByText("The API.")).toBeInTheDocument();
220
+ });
221
+
222
+ it("renders a translated page row and falls back to English for an untranslated one", async () => {
223
+ render(<HandbookContents />);
224
+ expect(await screen.findByRole("link", { name: /Ordine di lettura/ })).toBeInTheDocument();
225
+ expect(screen.queryByRole("link", { name: /Reading order/ })).not.toBeInTheDocument();
226
+ expect(screen.getByText("I tre percorsi.")).toBeInTheDocument();
227
+ expect(screen.getByRole("link", { name: /Frontend models/ })).toBeInTheDocument();
228
+ });
229
+
230
+ it("orders sections and pages by the untranslated keys", async () => {
231
+ render(<HandbookContents />);
232
+ const headings = await screen.findAllByRole("heading", { level: 2 });
233
+ // `order` and `section` stay English, so the translated section keeps its
234
+ // place rather than sorting under its Italian initial.
235
+ expect(headings.map((h) => h.textContent)).toEqual(["Da qui si parte", "Framework", "Backend"]);
236
+ expect(await screen.findByText("rulebook / frontend")).toBeInTheDocument();
237
+ });
238
+ });
239
+
240
+ it("falls back to raw section keys when the section load fails", async () => {
241
+ vi.mocked(HandbookSectionService.findMany).mockRejectedValueOnce(new Error("500"));
242
+ render(<HandbookContents />);
243
+ const headings = await screen.findAllByRole("heading", { level: 2 });
244
+ expect(headings.map((h) => h.textContent)).toEqual(["00-start-here", "02-framework", "03-backend"]);
245
+ expect(screen.getByRole("link", { name: /Reading order/ })).toBeInTheDocument();
246
+ });
247
+ });
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useFormatter, useTranslations } from "next-intl";
4
+ import { useMemo } from "react";
5
+
6
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
7
+
8
+ type HandbookIndexStatusProps = {
9
+ pages: HandbookPageInterface[];
10
+ };
11
+
12
+ /**
13
+ * `aiStatus` values written by the ingest. Only `failed` means the page is not
14
+ * answerable; `pending` and `in_progress` are a sync still running and say
15
+ * nothing about the index being wrong.
16
+ */
17
+ const FAILURE_STATUSES = new Set(["failed", "error"]);
18
+
19
+ /**
20
+ * The one line of index bookkeeping the reading surface keeps.
21
+ *
22
+ * Word counts and per-page AI badges left the contents page; how many pages
23
+ * there are and when they were last synced did not, because that is the one
24
+ * question the administrator who pressed Sincronizza actually has. Both values
25
+ * are derived from the loaded pages — nothing new is stored for either, and
26
+ * `updatedAt` is framework-managed.
27
+ */
28
+ export function HandbookIndexStatus({ pages }: HandbookIndexStatusProps) {
29
+ const t = useTranslations();
30
+ const format = useFormatter();
31
+
32
+ const lastSynced = useMemo(() => {
33
+ let latest: Date | undefined = undefined;
34
+
35
+ for (const page of pages) {
36
+ const updatedAt = page.updatedAt;
37
+ if (!(updatedAt instanceof Date) || Number.isNaN(updatedAt.getTime())) continue;
38
+ if (!latest || updatedAt.getTime() > latest.getTime()) latest = updatedAt;
39
+ }
40
+
41
+ return latest;
42
+ }, [pages]);
43
+
44
+ const failures = useMemo(
45
+ () => pages.filter((page) => FAILURE_STATUSES.has((page.aiStatus ?? "").toLowerCase())).length,
46
+ [pages],
47
+ );
48
+
49
+ return (
50
+ <span className="text-muted-foreground text-xs tabular-nums">
51
+ {t("handbook.contents.status", {
52
+ count: pages.length,
53
+ date: lastSynced ? format.dateTime(lastSynced, { dateStyle: "medium" }) : "",
54
+ })}
55
+ {failures > 0 ? (
56
+ <span className="text-destructive ms-2 tabular-nums">
57
+ {t("handbook.contents.failures", { count: failures })}
58
+ </span>
59
+ ) : null}
60
+ </span>
61
+ );
62
+ }
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { BookOpenIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { useEffect, useMemo, useRef } from "react";
6
+
7
+ import { MicroLabel } from "../../../../components";
8
+ import { Modules } from "../../../../core";
9
+ import { usePageUrlGenerator } from "../../../../hooks";
10
+ import { Link } from "../../../../shadcnui";
11
+ import { cn } from "../../../../utils/cn";
12
+ import { handbookPageTitle, handbookSectionTitle } from "../../data/handbookDisplay";
13
+ import { HandbookPageInterface } from "../../data/HandbookPageInterface";
14
+ import { HandbookSectionInterface } from "../../data/HandbookSectionInterface";
15
+
16
+ type HandbookPageNavigatorProps = {
17
+ pages: HandbookPageInterface[];
18
+ sections: HandbookSectionInterface[];
19
+ currentId: string;
20
+ };
21
+
22
+ /**
23
+ * The manual's section tree, beside the page being read.
24
+ *
25
+ * A list of links, NOT `RoundPageContainer`'s `layout="rail"`: the rail renders
26
+ * a `TabsContent` per tab, so 120 pages would mean 120 content nodes and a
27
+ * client-side tab switch. A manual wants 120 real, shareable URLs and a working
28
+ * back button, which is what an anchor gives and a tab does not.
29
+ *
30
+ * Degrades the same way the contents surface does: a page whose `section`
31
+ * matches no loaded section — or has none at all — lands in a trailing group,
32
+ * and if no sections loaded at all the distinct page keys are used as written.
33
+ */
34
+ export function HandbookPageNavigator({ pages, sections, currentId }: HandbookPageNavigatorProps) {
35
+ const t = useTranslations();
36
+ const generateUrl = usePageUrlGenerator();
37
+
38
+ const groups = useMemo(() => {
39
+ const byKey = new Map<string, HandbookPageInterface[]>();
40
+ for (const page of pages) {
41
+ const key = page.section ?? "";
42
+ const bucket = byKey.get(key);
43
+ if (bucket) bucket.push(page);
44
+ else byKey.set(key, [page]);
45
+ }
46
+
47
+ for (const bucket of byKey.values()) bucket.sort((a, b) => (a.order ?? "").localeCompare(b.order ?? ""));
48
+
49
+ const ordered: { key: string; title: string; pages: HandbookPageInterface[] }[] = [];
50
+ const consumed = new Set<string>();
51
+
52
+ for (const section of sections) {
53
+ const sectionPages = byKey.get(section.key);
54
+ if (!sectionPages) continue;
55
+ consumed.add(section.key);
56
+ ordered.push({ key: section.key, title: handbookSectionTitle(section), pages: sectionPages });
57
+ }
58
+
59
+ // Whatever the loaded sections did not describe: an unknown key, an empty
60
+ // one, or every key when the section load failed outright.
61
+ const leftovers = [...byKey.keys()].filter((key) => !consumed.has(key)).sort((a, b) => a.localeCompare(b));
62
+
63
+ for (const key of leftovers)
64
+ ordered.push({
65
+ key: key.length > 0 ? key : "__untitled",
66
+ title: key.length > 0 ? key : t("handbook.sections.untitled"),
67
+ pages: byKey.get(key) ?? [],
68
+ });
69
+
70
+ return ordered;
71
+ }, [pages, sections, t]);
72
+
73
+ const currentRef = useRef<HTMLAnchorElement | null>(null);
74
+
75
+ /*
76
+ Bring the current page into view inside the rail — 120 entries means it is
77
+ usually below the fold, and a position marker nobody can see marks nothing.
78
+
79
+ Deliberately NOT `scrollIntoView`: that walks up every scrollable ancestor,
80
+ so it would scroll the page itself and land the reader halfway down the
81
+ article they just opened. Setting `scrollTop` on the rail moves the rail and
82
+ only the rail.
83
+ */
84
+ useEffect(() => {
85
+ const link = currentRef.current;
86
+ if (!link) return;
87
+
88
+ const rail = link.closest<HTMLElement>(".overflow-y-auto");
89
+ if (!rail) return;
90
+
91
+ rail.scrollTop = link.offsetTop - rail.clientHeight / 2 + link.clientHeight / 2;
92
+ }, [currentId, groups]);
93
+
94
+ if (groups.length === 0) return null;
95
+
96
+ // No `max-h` / `overflow` on the nav: the reader's rail wrapper is the sticky
97
+ // scroll container, and a second one nested inside it would trap the scroll
98
+ // in the wrong element.
99
+ /*
100
+ Deliberately the same treatment as `HandbookPageToc`: an icon and a
101
+ micro-label at the top, entries at `text-xs` on a `border-s-2` rail, and the
102
+ current one marked by turning that rail `border-primary` rather than by
103
+ growing.
104
+
105
+ The previous version gave the current page `text-primary font-medium` with
106
+ no size class, so it inherited the base size and rendered far larger than
107
+ its neighbours — the entry moved instead of being marked, and there was no
108
+ rail to show position at all. The two rails of this page are the same kind
109
+ of object and now read as one.
110
+ */
111
+ return (
112
+ <div className="flex flex-col gap-y-2">
113
+ <div className="flex items-center gap-2">
114
+ <BookOpenIcon className="text-muted-foreground h-4 w-4" />
115
+ <MicroLabel>{t("handbook.reader.contents")}</MicroLabel>
116
+ </div>
117
+ <nav aria-label={t("handbook.reader.contents")} className="flex flex-col gap-y-3">
118
+ {groups.map((group) => (
119
+ <div key={group.key} className="flex flex-col gap-y-0.5">
120
+ <MicroLabel className="ps-3">{group.title}</MicroLabel>
121
+ {group.pages.map((page) => {
122
+ const isCurrent = page.id === currentId;
123
+ return (
124
+ <Link
125
+ key={page.id}
126
+ ref={isCurrent ? currentRef : undefined}
127
+ href={generateUrl({ page: Modules.HandbookPage, id: page.id })}
128
+ aria-current={isCurrent ? "page" : undefined}
129
+ className={cn(
130
+ "hover:text-primary block border-s-2 py-1 ps-3 text-start text-xs leading-tight transition-colors",
131
+ isCurrent ? "border-primary text-primary font-medium" : "text-muted-foreground border-transparent",
132
+ )}
133
+ >
134
+ {handbookPageTitle(page)}
135
+ </Link>
136
+ );
137
+ })}
138
+ </div>
139
+ ))}
140
+ </nav>
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { createContext, ReactNode, useContext } from "react";
5
+ import { SharedProvider } from "../../../contexts";
6
+ import { Modules } from "../../../core";
7
+ import { usePageUrlGenerator } from "../../../hooks";
8
+ import { BreadcrumbItemData } from "../../../interfaces";
9
+ import type { HandbookPageInterface, HandbookSectionInterface } from "../data";
10
+ import { handbookPageTitle, handbookSectionTitle } from "../data/handbookDisplay";
11
+
12
+ /**
13
+ * The handbook's page context.
14
+ *
15
+ * The reader's header was an empty bar with a book icon in it: the feature
16
+ * shipped no provider, and `RoundPageContainerTitle` reads its heading from
17
+ * `useSharedContext().title`
18
+ * (components/containers/RoundPageContainerTitle.tsx). With nothing supplying
19
+ * that context there was no title, no breadcrumb and no way back — so the page
20
+ * title was printed a second time inside the body instead.
21
+ *
22
+ * This is the provider every other feature already has, copied from
23
+ * `RoleProvider` (features/role/contexts/RoleContext.tsx): a `breadcrumb()`, a
24
+ * `title()`, and `SharedProvider` wrapping the feature's own context. Every
25
+ * handbook surface — contents, reader, ask — mounts it, so all three get the
26
+ * same header for free.
27
+ */
28
+
29
+ interface HandbookContextType {
30
+ page: HandbookPageInterface | undefined;
31
+ section: HandbookSectionInterface | undefined;
32
+ }
33
+
34
+ const HandbookContext = createContext<HandbookContextType | undefined>(undefined);
35
+
36
+ type HandbookProviderProps = {
37
+ children: ReactNode;
38
+ /** Undefined on the contents and ask surfaces. */
39
+ page?: HandbookPageInterface;
40
+ /** The page's section, when one is loaded and resolvable. */
41
+ section?: HandbookSectionInterface;
42
+ /** Rendered into `title.functions`. */
43
+ functions?: ReactNode;
44
+ /** Overrides the default `title.type`. Used by the ask surface. */
45
+ titleType?: string;
46
+ };
47
+
48
+ export const HandbookProvider = ({ children, page, section, functions, titleType }: HandbookProviderProps) => {
49
+ const generateUrl = usePageUrlGenerator();
50
+ const t = useTranslations();
51
+
52
+ const breadcrumb = () => {
53
+ const response: BreadcrumbItemData[] = [];
54
+
55
+ response.push({
56
+ name: t(`handbook.title`),
57
+ href: generateUrl({ page: Modules.HandbookPage }),
58
+ });
59
+
60
+ if (page)
61
+ response.push({
62
+ name: handbookPageTitle(page),
63
+ href: generateUrl({ page: Modules.HandbookPage, id: page.id }),
64
+ });
65
+
66
+ return response;
67
+ };
68
+
69
+ const title = () => {
70
+ const response: any = {
71
+ // The reader is titled by where the page sits, not by the manual it
72
+ // belongs to: the section is the `type` and the page is the `element`,
73
+ // the same split every entity detail page in the product uses. Sections
74
+ // are loaded as their own resource, so a page whose section has not
75
+ // resolved falls back to the raw key it carries — and the key is the
76
+ // directory name, which is not translated and never was.
77
+ type: titleType ?? (page ? (section ? handbookSectionTitle(section) : page.section) : t(`handbook.title`)),
78
+ };
79
+
80
+ if (page) {
81
+ response.element = handbookPageTitle(page);
82
+ }
83
+
84
+ if (functions) response.functions = functions;
85
+
86
+ return response;
87
+ };
88
+
89
+ return (
90
+ <SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
91
+ <HandbookContext.Provider
92
+ value={{
93
+ page: page,
94
+ section: section,
95
+ }}
96
+ >
97
+ {children}
98
+ </HandbookContext.Provider>
99
+ </SharedProvider>
100
+ );
101
+ };
102
+
103
+ export const useHandbookContext = (): HandbookContextType => {
104
+ const context = useContext(HandbookContext);
105
+ if (context === undefined) {
106
+ throw new Error("useHandbookContext must be used within a HandbookProvider");
107
+ }
108
+ return context;
109
+ };