@carlonicora/nextjs-jsonapi 3.11.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.
- package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
- package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
- package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
- package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
- package/dist/{BlockNoteEditor-XOVNTXDY.js → BlockNoteEditor-CFVSHS46.js} +19 -19
- package/dist/{BlockNoteEditor-XOVNTXDY.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
- package/dist/{BlockNoteEditor-NBHDEZ7W.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
- package/dist/{BlockNoteViewer-C2UUOBAF.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
- package/dist/{BlockNoteViewer-C2UUOBAF.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
- package/dist/{BlockNoteViewer-TYLVTIUD.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
- package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
- package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
- package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
- package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
- package/dist/billing/index.d.mts +27 -0
- package/dist/billing/index.d.ts +27 -0
- package/dist/billing/index.js +697 -450
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +282 -35
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-6J2SQZ3G.js → chunk-IB7DTXWG.js} +847 -129
- package/dist/chunk-IB7DTXWG.js.map +1 -0
- package/dist/{chunk-YN4LOVL4.js → chunk-LVE3BJ6K.js} +2106 -1109
- package/dist/chunk-LVE3BJ6K.js.map +1 -0
- package/dist/{chunk-M5QAHSCY.mjs → chunk-OSFYCSTW.mjs} +725 -7
- package/dist/chunk-OSFYCSTW.mjs.map +1 -0
- package/dist/{chunk-XNGI4HDZ.mjs → chunk-UQXE3SME.mjs} +2100 -1103
- package/dist/chunk-UQXE3SME.mjs.map +1 -0
- package/dist/{chunk-IMSIVIIC.mjs → chunk-VZKJIAND.mjs} +2 -2
- package/dist/{chunk-V6CGCARD.js → chunk-XH4IURUD.js} +7 -7
- package/dist/{chunk-V6CGCARD.js.map → chunk-XH4IURUD.js.map} +1 -1
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +258 -7
- package/dist/components/index.d.ts +258 -7
- package/dist/components/index.js +30 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +29 -3
- package/dist/contexts/index.d.mts +17 -5
- package/dist/contexts/index.d.ts +17 -5
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +493 -12
- package/dist/core/index.d.ts +493 -12
- package/dist/core/index.js +38 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +37 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +7 -7
- package/dist/index.d.ts +7 -7
- package/dist/index.js +39 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +38 -2
- package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
- package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
- package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
- package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
- package/dist/server/index.d.mts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
- package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
- package/package.json +1 -1
- package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +5 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
- package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
- package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
- package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
- package/src/features/assistant/data/Assistant.ts +2 -0
- package/src/features/assistant/data/AssistantInterface.ts +2 -0
- package/src/features/assistant/data/AssistantService.ts +4 -0
- package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
- package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
- package/src/features/billing/i18n-keys.ts +7 -0
- package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +108 -2
- package/src/features/billing/stripe-price/components/forms/__tests__/PriceEditor.spec.tsx +182 -0
- package/src/features/billing/stripe-price/components/lists/PricesList.tsx +152 -25
- package/src/features/billing/stripe-price/components/lists/__tests__/PricesList.spec.tsx +268 -0
- package/src/features/billing/stripe-price/data/__tests__/stripe-price.transfer.spec.ts +265 -0
- package/src/features/billing/stripe-price/data/stripe-price.fields.ts +1 -0
- package/src/features/billing/stripe-price/data/stripe-price.transfer.ts +194 -0
- package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +66 -2
- package/src/features/content/data/content.interface.ts +1 -1
- package/src/features/handbook/HandbookPageModule.ts +41 -0
- package/src/features/handbook/HandbookSectionModule.ts +35 -0
- package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
- package/src/features/handbook/HandbookThreadModule.ts +29 -0
- package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
- package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
- package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
- package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
- package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
- package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
- package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
- package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
- package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
- package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
- package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
- package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
- package/src/features/handbook/components/index.ts +12 -0
- package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
- package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
- package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
- package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
- package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
- package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
- package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
- package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
- package/src/features/handbook/data/HandbookPage.ts +99 -0
- package/src/features/handbook/data/HandbookPageFields.ts +31 -0
- package/src/features/handbook/data/HandbookPageInput.ts +8 -0
- package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
- package/src/features/handbook/data/HandbookPageService.ts +60 -0
- package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
- package/src/features/handbook/data/HandbookSection.ts +73 -0
- package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
- package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
- package/src/features/handbook/data/HandbookSectionService.ts +26 -0
- package/src/features/handbook/data/HandbookThread.ts +114 -0
- package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
- package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
- package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
- package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
- package/src/features/handbook/data/HandbookThreadService.ts +86 -0
- package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
- package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
- package/src/features/handbook/data/handbookDisplay.ts +55 -0
- package/src/features/handbook/data/index.ts +16 -0
- package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
- package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
- package/src/features/handbook/i18n-keys.ts +46 -0
- package/src/features/handbook/index.ts +7 -0
- package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
- package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
- package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
- package/src/features/how-to/components/index.ts +1 -0
- package/src/features/how-to/components/lists/HowToList.tsx +5 -1
- package/src/features/how-to/data/HowTo.spec.ts +61 -1
- package/src/features/how-to/data/HowToService.ts +13 -0
- package/dist/chunk-6J2SQZ3G.js.map +0 -1
- package/dist/chunk-M5QAHSCY.mjs.map +0 -1
- package/dist/chunk-XNGI4HDZ.mjs.map +0 -1
- package/dist/chunk-YN4LOVL4.js.map +0 -1
- /package/dist/{BlockNoteEditor-NBHDEZ7W.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-TYLVTIUD.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
- /package/dist/{chunk-IMSIVIIC.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { RoundPageContainer } from "../../../../components/containers/RoundPageContainer";
|
|
7
|
+
import { Modules } from "../../../../core";
|
|
8
|
+
import { HandbookProvider } from "../../contexts/HandbookContext";
|
|
9
|
+
import { handbookSectionSummary, handbookSectionTitle } from "../../data/handbookDisplay";
|
|
10
|
+
import { HandbookPageInterface } from "../../data/HandbookPageInterface";
|
|
11
|
+
import { HandbookPageService } from "../../data/HandbookPageService";
|
|
12
|
+
import { HandbookSectionInterface } from "../../data/HandbookSectionInterface";
|
|
13
|
+
import { HandbookSectionService } from "../../data/HandbookSectionService";
|
|
14
|
+
import { HandbookAskSheet } from "../containers/HandbookAskSheet";
|
|
15
|
+
import HandbookSyncButton from "../forms/HandbookSyncButton";
|
|
16
|
+
import { HandbookIndexStatus } from "../parts/HandbookIndexStatus";
|
|
17
|
+
import { HandbookSectionGroup } from "./HandbookSectionGroup";
|
|
18
|
+
|
|
19
|
+
type RenderedSection = {
|
|
20
|
+
key: string;
|
|
21
|
+
title: string;
|
|
22
|
+
summary?: string;
|
|
23
|
+
pages: HandbookPageInterface[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The contents surface: the manual's table of contents, replacing the four
|
|
28
|
+
* column table of indexed files.
|
|
29
|
+
*
|
|
30
|
+
* It owns `RoundPageContainer` rather than being handed one, because
|
|
31
|
+
* `RoundPageContainerTitle` reads its heading from `useSharedContext()` and the
|
|
32
|
+
* provider that supplies it therefore has to be an ANCESTOR of the container —
|
|
33
|
+
* exactly the composition `HandbookAskContainer` already uses. See the
|
|
34
|
+
* hand-off note: the plan put the provider inside the container, which throws.
|
|
35
|
+
*/
|
|
36
|
+
export default function HandbookContents() {
|
|
37
|
+
const t = useTranslations();
|
|
38
|
+
|
|
39
|
+
const [pages, setPages] = useState<HandbookPageInterface[]>([]);
|
|
40
|
+
const [sections, setSections] = useState<HandbookSectionInterface[]>([]);
|
|
41
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
42
|
+
const [reloadToken, setReloadToken] = useState(0);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
|
|
47
|
+
const load = async () => {
|
|
48
|
+
setIsLoading(true);
|
|
49
|
+
|
|
50
|
+
// The two loads are INDEPENDENT on purpose. A failed section read must
|
|
51
|
+
// not take the pages down with it: without sections the contents page
|
|
52
|
+
// falls back to the raw section keys, which is exactly what an nja
|
|
53
|
+
// application with no README index renders anyway.
|
|
54
|
+
const loadedPages = HandbookPageService.findMany({ fetchAll: true })
|
|
55
|
+
.then((result) => {
|
|
56
|
+
if (!cancelled) setPages(result ?? []);
|
|
57
|
+
})
|
|
58
|
+
.catch(() => {
|
|
59
|
+
if (!cancelled) setPages([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const loadedSections = HandbookSectionService.findMany()
|
|
63
|
+
.then((result) => {
|
|
64
|
+
if (!cancelled) setSections(result ?? []);
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
if (!cancelled) setSections([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await Promise.allSettled([loadedPages, loadedSections]);
|
|
71
|
+
if (!cancelled) setIsLoading(false);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
void load();
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
cancelled = true;
|
|
78
|
+
};
|
|
79
|
+
}, [reloadToken]);
|
|
80
|
+
|
|
81
|
+
/** `HandbookSyncButton` awaits this before it toasts; re-running the effect refetches both resources. */
|
|
82
|
+
const refresh = useCallback(async () => {
|
|
83
|
+
setReloadToken((token) => token + 1);
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const renderedSections = useMemo(
|
|
87
|
+
() => buildSections({ pages, sections, untitled: t("handbook.sections.untitled") }),
|
|
88
|
+
[pages, sections, t],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<HandbookProvider
|
|
93
|
+
functions={
|
|
94
|
+
<>
|
|
95
|
+
<HandbookAskSheet />
|
|
96
|
+
<HandbookSyncButton refresh={refresh} />
|
|
97
|
+
<HandbookIndexStatus pages={pages} />
|
|
98
|
+
</>
|
|
99
|
+
}
|
|
100
|
+
>
|
|
101
|
+
{/*
|
|
102
|
+
NOT `fullWidth`. That flag strips the container's `mx-auto max-w-6xl`
|
|
103
|
+
and its padding (RoundPageContainer.tsx:477-484) — it exists for the
|
|
104
|
+
edge-to-edge table this page replaced. A contents page is prose: it
|
|
105
|
+
wants the same centred, padded column every other non-table page in the
|
|
106
|
+
app uses, and a summary line running to 1900px is unreadable.
|
|
107
|
+
*/}
|
|
108
|
+
<RoundPageContainer module={Modules.HandbookPage} forceHeader>
|
|
109
|
+
{isLoading ? null : renderedSections.length === 0 ? (
|
|
110
|
+
<span className="text-muted-foreground text-sm">{t("handbook.empty")}</span>
|
|
111
|
+
) : (
|
|
112
|
+
<div className="flex w-full flex-col gap-10">
|
|
113
|
+
{renderedSections.map((section) => (
|
|
114
|
+
<HandbookSectionGroup
|
|
115
|
+
key={section.key}
|
|
116
|
+
title={section.title}
|
|
117
|
+
summary={section.summary}
|
|
118
|
+
pages={section.pages}
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</RoundPageContainer>
|
|
124
|
+
</HandbookProvider>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Turns the two loaded lists into the sections the page renders, in order.
|
|
130
|
+
*
|
|
131
|
+
* Three degradations, all of them load-bearing:
|
|
132
|
+
*
|
|
133
|
+
* 1. No sections loaded — the endpoint failed, or the application has no
|
|
134
|
+
* README index — falls back to the distinct `section` values of the pages,
|
|
135
|
+
* sorted, with the raw key as the heading and no blurb.
|
|
136
|
+
* 2. A section declared by the index but matching no page renders with its
|
|
137
|
+
* blurb and an empty list. Visible drift beats a silently dropped heading.
|
|
138
|
+
* 3. A page whose `section` is empty or matches no loaded section lands in a
|
|
139
|
+
* trailing group. A file at the root of the tree is one of those.
|
|
140
|
+
*
|
|
141
|
+
* The grouping and both sorts read `section`, `order` and `path`, which stay
|
|
142
|
+
* English: the manual is indexed in English and only shown translated. The two
|
|
143
|
+
* strings a section actually prints go through the display helpers, so a
|
|
144
|
+
* translated section renders translated and an untranslated one renders the
|
|
145
|
+
* English original it was indexed under.
|
|
146
|
+
*/
|
|
147
|
+
function buildSections(params: {
|
|
148
|
+
pages: HandbookPageInterface[];
|
|
149
|
+
sections: HandbookSectionInterface[];
|
|
150
|
+
untitled: string;
|
|
151
|
+
}): RenderedSection[] {
|
|
152
|
+
const byOrder = (a: string, b: string) => a.localeCompare(b);
|
|
153
|
+
|
|
154
|
+
const ordered: { key: string; title: string; summary?: string }[] =
|
|
155
|
+
params.sections.length > 0
|
|
156
|
+
? [...params.sections]
|
|
157
|
+
.sort((a, b) => byOrder(a.order || a.key, b.order || b.key))
|
|
158
|
+
.map((section) => ({
|
|
159
|
+
key: section.key,
|
|
160
|
+
title: handbookSectionTitle(section) || section.key,
|
|
161
|
+
summary: handbookSectionSummary(section),
|
|
162
|
+
}))
|
|
163
|
+
: Array.from(new Set(params.pages.map((page) => page.section).filter((key) => !!key)))
|
|
164
|
+
.sort(byOrder)
|
|
165
|
+
.map((key) => ({ key, title: key }));
|
|
166
|
+
|
|
167
|
+
const known = new Set(ordered.map((section) => section.key));
|
|
168
|
+
|
|
169
|
+
const grouped = new Map<string, HandbookPageInterface[]>();
|
|
170
|
+
const leftovers: HandbookPageInterface[] = [];
|
|
171
|
+
|
|
172
|
+
for (const page of params.pages) {
|
|
173
|
+
const key = page.section;
|
|
174
|
+
if (!key || !known.has(key)) {
|
|
175
|
+
leftovers.push(page);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const existing = grouped.get(key);
|
|
180
|
+
if (existing) existing.push(page);
|
|
181
|
+
else grouped.set(key, [page]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const sortPages = (pages: HandbookPageInterface[]) =>
|
|
185
|
+
[...pages].sort((a, b) => byOrder(a.order || a.path || a.title, b.order || b.path || b.title));
|
|
186
|
+
|
|
187
|
+
const response: RenderedSection[] = ordered.map((section) => ({
|
|
188
|
+
...section,
|
|
189
|
+
pages: sortPages(grouped.get(section.key) ?? []),
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
if (leftovers.length > 0)
|
|
193
|
+
response.push({ key: "", title: params.untitled, summary: undefined, pages: sortPages(leftovers) });
|
|
194
|
+
|
|
195
|
+
return response;
|
|
196
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
import { MicroLabel, SectionHeader } from "../../../../components/typography";
|
|
6
|
+
import { Modules } from "../../../../core";
|
|
7
|
+
import { usePageUrlGenerator } from "../../../../hooks";
|
|
8
|
+
import { Link } from "../../../../shadcnui";
|
|
9
|
+
import { handbookPageSummary, handbookPageTitle } from "../../data/handbookDisplay";
|
|
10
|
+
import { HandbookPageInterface } from "../../data/HandbookPageInterface";
|
|
11
|
+
|
|
12
|
+
type HandbookSectionGroupProps = {
|
|
13
|
+
/** Rendered as the section's `h2`. Already prettified and translated by the caller. */
|
|
14
|
+
title: string;
|
|
15
|
+
/** The README blurb for the section, already translated by the caller. */
|
|
16
|
+
summary?: string;
|
|
17
|
+
/** The section's pages, already sorted by `order` by the caller. */
|
|
18
|
+
pages: HandbookPageInterface[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One section of the contents page: a heading, its blurb, and its pages.
|
|
23
|
+
*
|
|
24
|
+
* A page whose path nests below the section's own directory
|
|
25
|
+
* (`02-framework/rulebook/frontend/01-models.md` inside `02-framework`) is not
|
|
26
|
+
* a peer of the section's direct pages, so it renders under a micro-label
|
|
27
|
+
* naming the intermediate directories. The nesting is read off the path rather
|
|
28
|
+
* than stored, because the filesystem is the only place it is declared.
|
|
29
|
+
*/
|
|
30
|
+
export function HandbookSectionGroup({ title, summary, pages }: HandbookSectionGroupProps) {
|
|
31
|
+
const generateUrl = usePageUrlGenerator();
|
|
32
|
+
|
|
33
|
+
const { direct, nested } = useMemo(() => splitByNesting(pages), [pages]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<section className="flex flex-col gap-4">
|
|
37
|
+
{/*
|
|
38
|
+
The rule belongs to the heading block, not between the sections: it
|
|
39
|
+
marks where a section starts, so the generous gap above it reads as
|
|
40
|
+
separation and the tight gap below it as belonging.
|
|
41
|
+
*/}
|
|
42
|
+
<div className="flex flex-col gap-1 border-b pb-3">
|
|
43
|
+
<SectionHeader level={2}>{title}</SectionHeader>
|
|
44
|
+
{summary ? <p className="text-muted-foreground text-sm">{summary}</p> : null}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{direct.length > 0 ? <HandbookPageRows pages={direct} generateUrl={generateUrl} /> : null}
|
|
48
|
+
|
|
49
|
+
{nested.map((group) => (
|
|
50
|
+
<div key={group.label} className="flex flex-col gap-1.5 pt-2">
|
|
51
|
+
<MicroLabel>{group.label}</MicroLabel>
|
|
52
|
+
<HandbookPageRows pages={group.pages} generateUrl={generateUrl} />
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</section>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function HandbookPageRows({
|
|
60
|
+
pages,
|
|
61
|
+
generateUrl,
|
|
62
|
+
}: {
|
|
63
|
+
pages: HandbookPageInterface[];
|
|
64
|
+
generateUrl: ReturnType<typeof usePageUrlGenerator>;
|
|
65
|
+
}) {
|
|
66
|
+
return (
|
|
67
|
+
<ul className="divide-border/60 flex flex-col divide-y">
|
|
68
|
+
{pages.map((page) => {
|
|
69
|
+
// The translation when the ingest matched one, the English original
|
|
70
|
+
// when it did not. Resolved once per row rather than per element.
|
|
71
|
+
const summary = handbookPageSummary(page);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<li key={page.id}>
|
|
75
|
+
{/*
|
|
76
|
+
The whole row is the link, not just the title: the summary describes
|
|
77
|
+
the same destination, and a second click target beside it would be a
|
|
78
|
+
second thing to aim at for no second outcome.
|
|
79
|
+
|
|
80
|
+
`text-sm` on the title and `text-xs` on the summary, not the
|
|
81
|
+
inherited base size — a row is a detail pair (roles 13 and 12), and
|
|
82
|
+
at base size it competed with the section heading above it instead
|
|
83
|
+
of sitting under it.
|
|
84
|
+
*/}
|
|
85
|
+
<Link
|
|
86
|
+
href={generateUrl({ page: Modules.HandbookPage, id: page.id })}
|
|
87
|
+
className="hover:bg-muted/40 -mx-2 block rounded-md px-2 py-2"
|
|
88
|
+
>
|
|
89
|
+
<span className="text-primary block text-sm font-medium">{handbookPageTitle(page)}</span>
|
|
90
|
+
{summary ? (
|
|
91
|
+
<span className="text-muted-foreground mt-0.5 block text-xs font-normal">{sentenceCase(summary)}</span>
|
|
92
|
+
) : null}
|
|
93
|
+
</Link>
|
|
94
|
+
</li>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</ul>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The README writes each summary as a continuation of its link — "— boot, log
|
|
103
|
+
* in, create a proceeding" — so pasted in raw every row starts lowercase. Here
|
|
104
|
+
* the summary is a sentence of its own and reads as one.
|
|
105
|
+
*
|
|
106
|
+
* Only an ASCII letter is raised: several summaries open with a backtick
|
|
107
|
+
* (`` `defineEntity()` and everything derived from it ``) or a quotation mark,
|
|
108
|
+
* and those must survive untouched.
|
|
109
|
+
*/
|
|
110
|
+
function sentenceCase(summary: string): string {
|
|
111
|
+
const first = summary.charAt(0);
|
|
112
|
+
if (first < "a" || first > "z") return summary;
|
|
113
|
+
return first.toUpperCase() + summary.slice(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Splits a section's pages into the ones that sit directly in its directory and
|
|
118
|
+
* the ones that sit in a directory below it, keyed by the intermediate path.
|
|
119
|
+
*
|
|
120
|
+
* `03-backend/testing.md` is direct. `02-framework/rulebook/frontend/01-models.md`
|
|
121
|
+
* belongs to the group `rulebook / frontend`. The groups come out in path order
|
|
122
|
+
* and always after the direct pages, so a section reads as its own pages first
|
|
123
|
+
* and its sub-topics after.
|
|
124
|
+
*/
|
|
125
|
+
function splitByNesting(pages: HandbookPageInterface[]): {
|
|
126
|
+
direct: HandbookPageInterface[];
|
|
127
|
+
nested: { label: string; pages: HandbookPageInterface[] }[];
|
|
128
|
+
} {
|
|
129
|
+
const direct: HandbookPageInterface[] = [];
|
|
130
|
+
const groups = new Map<string, HandbookPageInterface[]>();
|
|
131
|
+
|
|
132
|
+
for (const page of pages) {
|
|
133
|
+
const segments = (page.order || page.path || "").split("/").filter((segment) => segment.length > 0);
|
|
134
|
+
// Drop the filename, then the section's own directory.
|
|
135
|
+
const intermediate = segments.slice(0, -1).slice(1);
|
|
136
|
+
|
|
137
|
+
if (intermediate.length === 0) {
|
|
138
|
+
direct.push(page);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const label = intermediate.join(" / ");
|
|
143
|
+
const existing = groups.get(label);
|
|
144
|
+
if (existing) existing.push(page);
|
|
145
|
+
else groups.set(label, [page]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const nested = Array.from(groups.entries())
|
|
149
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
150
|
+
.map(([label, groupPages]) => ({ label, pages: groupPages }));
|
|
151
|
+
|
|
152
|
+
return { direct, nested };
|
|
153
|
+
}
|
|
@@ -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
|
+
}
|