@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.
- 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-6G27S6P3.js → BlockNoteEditor-CFVSHS46.js} +19 -19
- package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
- package/dist/{BlockNoteEditor-CR5KH4AI.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
- package/dist/{BlockNoteViewer-5MITJ7AD.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
- package/dist/{BlockNoteViewer-5MITJ7AD.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
- package/dist/{BlockNoteViewer-NJKFUNXP.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.js +401 -401
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
- package/dist/chunk-IB7DTXWG.js.map +1 -0
- package/dist/{chunk-OKGGYCO5.js → chunk-LVE3BJ6K.js} +2088 -1134
- package/dist/chunk-LVE3BJ6K.js.map +1 -0
- package/dist/{chunk-Q2BREXH3.mjs → chunk-OSFYCSTW.mjs} +724 -7
- package/dist/chunk-OSFYCSTW.mjs.map +1 -0
- package/dist/{chunk-JEGL7KHK.mjs → chunk-UQXE3SME.mjs} +2081 -1127
- package/dist/chunk-UQXE3SME.mjs.map +1 -0
- package/dist/{chunk-HVNEWDRF.mjs → chunk-VZKJIAND.mjs} +2 -2
- package/dist/{chunk-4ETVCRQD.js → chunk-XH4IURUD.js} +7 -7
- package/dist/{chunk-4ETVCRQD.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 +491 -11
- package/dist/core/index.d.ts +491 -11
- 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/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-JEGL7KHK.mjs.map +0 -1
- package/dist/chunk-L7DZSTFU.js.map +0 -1
- package/dist/chunk-OKGGYCO5.js.map +0 -1
- package/dist/chunk-Q2BREXH3.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-CR5KH4AI.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-NJKFUNXP.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
- /package/dist/{chunk-HVNEWDRF.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { List } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useCallback, useEffect, useState, type MouseEvent as ReactMouseEvent } from "react";
|
|
6
|
+
|
|
7
|
+
import { MicroLabel } from "../../../../components";
|
|
8
|
+
import { cn } from "../../../../utils/cn";
|
|
9
|
+
|
|
10
|
+
type HandbookPageTocProps = {
|
|
11
|
+
/** The `id` of the element holding the rendered markdown. */
|
|
12
|
+
containerId: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type TocHeading = {
|
|
16
|
+
id: string;
|
|
17
|
+
text: string;
|
|
18
|
+
level: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The heading index of the page being read.
|
|
23
|
+
*
|
|
24
|
+
* Follows `HowToTableOfContents` (apps/web how-to detail): the same
|
|
25
|
+
* IntersectionObserver tracking, the same rootMargin, the same
|
|
26
|
+
* topmost-visible-wins rule and the same smooth scroll on click. Only the
|
|
27
|
+
* heading SOURCE differs — the how-to reads BlockNote blocks and assigns the
|
|
28
|
+
* ids itself, this one reads the ids `rehype-slug` already put on the rendered
|
|
29
|
+
* markdown, so the entries are anchors with a real `href` rather than buttons.
|
|
30
|
+
*/
|
|
31
|
+
export function HandbookPageToc({ containerId }: HandbookPageTocProps) {
|
|
32
|
+
const t = useTranslations();
|
|
33
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
34
|
+
const [headings, setHeadings] = useState<TocHeading[]>([]);
|
|
35
|
+
const [headingsWithIds, setHeadingsWithIds] = useState<Map<string, Element>>(new Map());
|
|
36
|
+
|
|
37
|
+
// Read the headings out of the rendered markdown, and re-read them whenever
|
|
38
|
+
// the container's content changes — a different page renders into the same
|
|
39
|
+
// container without this component ever unmounting.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const contentPanel = document.getElementById(containerId);
|
|
42
|
+
if (!contentPanel) return;
|
|
43
|
+
|
|
44
|
+
const collect = () => {
|
|
45
|
+
const headingElements = contentPanel.querySelectorAll("h1, h2, h3");
|
|
46
|
+
const nextHeadings: TocHeading[] = [];
|
|
47
|
+
const nextMap = new Map<string, Element>();
|
|
48
|
+
|
|
49
|
+
headingElements.forEach((el) => {
|
|
50
|
+
if (!el.id) return;
|
|
51
|
+
nextHeadings.push({
|
|
52
|
+
id: el.id,
|
|
53
|
+
text: el.textContent ?? "",
|
|
54
|
+
level: Number(el.tagName.slice(1)),
|
|
55
|
+
});
|
|
56
|
+
nextMap.set(el.id, el);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
setHeadings(nextHeadings);
|
|
60
|
+
setHeadingsWithIds(nextMap);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
collect();
|
|
64
|
+
|
|
65
|
+
const observer = new MutationObserver(collect);
|
|
66
|
+
observer.observe(contentPanel, { childList: true, subtree: true });
|
|
67
|
+
|
|
68
|
+
return () => observer.disconnect();
|
|
69
|
+
}, [containerId]);
|
|
70
|
+
|
|
71
|
+
// Set up IntersectionObserver for scroll tracking
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (headingsWithIds.size === 0) return;
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
`root: null` — the VIEWPORT, deliberately.
|
|
77
|
+
|
|
78
|
+
The previous version looked for a `.overflow-y-auto` descendant of the
|
|
79
|
+
content panel and used that as the root. There is none: the element that
|
|
80
|
+
actually scrolls is an ANCESTOR (the page container's scroll body), so the
|
|
81
|
+
root fell back to the content panel itself, which never moves relative to
|
|
82
|
+
its own children. The observer therefore fired once on mount and never
|
|
83
|
+
again, and the only thing that ever changed the highlight was a click.
|
|
84
|
+
|
|
85
|
+
Viewport-relative intersection needs no knowledge of which ancestor
|
|
86
|
+
scrolls — the headings move through the viewport either way.
|
|
87
|
+
*/
|
|
88
|
+
const observer = new IntersectionObserver(
|
|
89
|
+
(entries) => {
|
|
90
|
+
// Find the topmost visible heading
|
|
91
|
+
const visibleEntries = entries.filter((entry) => entry.isIntersecting);
|
|
92
|
+
|
|
93
|
+
if (visibleEntries.length > 0) {
|
|
94
|
+
// Sort by their position in the document and pick the first
|
|
95
|
+
const topEntry = visibleEntries.reduce((top, entry) => {
|
|
96
|
+
const topRect = top.boundingClientRect;
|
|
97
|
+
const entryRect = entry.boundingClientRect;
|
|
98
|
+
return entryRect.top < topRect.top ? entry : top;
|
|
99
|
+
});
|
|
100
|
+
setActiveId(topEntry.target.id);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
root: null,
|
|
105
|
+
// The active band is the top tenth to top third of the viewport: a
|
|
106
|
+
// heading is "where you are" once it has reached reading position, not
|
|
107
|
+
// when it is still at the bottom edge.
|
|
108
|
+
rootMargin: "-10% 0px -70% 0px",
|
|
109
|
+
threshold: 0,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
headingsWithIds.forEach((element) => {
|
|
114
|
+
observer.observe(element);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return () => observer.disconnect();
|
|
118
|
+
}, [containerId, headingsWithIds]);
|
|
119
|
+
|
|
120
|
+
const handleClick = useCallback(
|
|
121
|
+
(event: ReactMouseEvent<HTMLAnchorElement>, id: string) => {
|
|
122
|
+
const element = headingsWithIds.get(id);
|
|
123
|
+
if (!element) return;
|
|
124
|
+
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
127
|
+
// Deliberately NOT setActiveId(id). The highlight answers "where am I",
|
|
128
|
+
// and the observer answers that as the scroll lands. Setting it here made
|
|
129
|
+
// it answer "where did I click" instead, and it stuck there.
|
|
130
|
+
},
|
|
131
|
+
[headingsWithIds],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Don't render if no headings
|
|
135
|
+
if (headings.length === 0) return null;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex flex-col gap-y-2">
|
|
139
|
+
<div className="flex items-center gap-2">
|
|
140
|
+
<List className="text-muted-foreground h-4 w-4" />
|
|
141
|
+
<MicroLabel>{t("handbook.reader.onThisPage")}</MicroLabel>
|
|
142
|
+
</div>
|
|
143
|
+
<nav aria-label={t("handbook.reader.onThisPage")} className="flex flex-col gap-y-0.5">
|
|
144
|
+
{headings.map((heading) => (
|
|
145
|
+
<a
|
|
146
|
+
key={heading.id}
|
|
147
|
+
href={`#${heading.id}`}
|
|
148
|
+
onClick={(event) => handleClick(event, heading.id)}
|
|
149
|
+
className={cn(
|
|
150
|
+
"hover:text-primary border-s-2 py-1 text-start text-xs transition-colors",
|
|
151
|
+
heading.level === 1 && "ps-3",
|
|
152
|
+
heading.level === 2 && "ps-5",
|
|
153
|
+
heading.level === 3 && "ps-7",
|
|
154
|
+
activeId === heading.id
|
|
155
|
+
? "border-primary text-primary font-medium"
|
|
156
|
+
: "text-muted-foreground border-transparent",
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
{heading.text}
|
|
160
|
+
</a>
|
|
161
|
+
))}
|
|
162
|
+
</nav>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "../../../../shadcnui";
|
|
7
|
+
import { showError, showToast } from "../../../../utils/toast";
|
|
8
|
+
import { HandbookPageService } from "../../data/HandbookPageService";
|
|
9
|
+
|
|
10
|
+
type HandbookSyncButtonProps = {
|
|
11
|
+
refresh: () => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The sync control is a plain Button, not a trigger: it opens nothing, so the
|
|
16
|
+
* render-prop composition rule for Base UI triggers does not apply here.
|
|
17
|
+
*/
|
|
18
|
+
export default function HandbookSyncButton({ refresh }: HandbookSyncButtonProps) {
|
|
19
|
+
const t = useTranslations();
|
|
20
|
+
const [isSyncing, setIsSyncing] = useState(false);
|
|
21
|
+
|
|
22
|
+
const onSync = async () => {
|
|
23
|
+
setIsSyncing(true);
|
|
24
|
+
try {
|
|
25
|
+
await HandbookPageService.sync();
|
|
26
|
+
await refresh();
|
|
27
|
+
showToast(t(`handbook.synced`));
|
|
28
|
+
} catch {
|
|
29
|
+
// The most common failure is a consumer with no `handbook.path`
|
|
30
|
+
// configured, which the API answers with 400. Surface it and leave the
|
|
31
|
+
// list untouched.
|
|
32
|
+
showError(t(`handbook.syncFailed`));
|
|
33
|
+
} finally {
|
|
34
|
+
setIsSyncing(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Button onClick={onSync} disabled={isSyncing}>
|
|
40
|
+
{isSyncing ? t(`handbook.syncing`) : t(`handbook.sync`)}
|
|
41
|
+
</Button>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { HandbookPageService } from "../../../data/HandbookPageService";
|
|
6
|
+
import HandbookSyncButton from "../HandbookSyncButton";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../../data/HandbookPageService", () => ({
|
|
9
|
+
HandbookPageService: { sync: vi.fn() },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("next-intl", () => ({
|
|
13
|
+
useTranslations: () => (key: string) => key,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("HandbookSyncButton", () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.mocked(HandbookPageService.sync).mockReset().mockResolvedValue(undefined);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("syncs, then refreshes the list", async () => {
|
|
22
|
+
const refresh = vi.fn().mockResolvedValue(undefined);
|
|
23
|
+
render(<HandbookSyncButton refresh={refresh} />);
|
|
24
|
+
|
|
25
|
+
await userEvent.click(screen.getByRole("button"));
|
|
26
|
+
|
|
27
|
+
await waitFor(() => expect(HandbookPageService.sync).toHaveBeenCalledTimes(1));
|
|
28
|
+
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not refresh when the sync fails, and re-enables itself", async () => {
|
|
32
|
+
vi.mocked(HandbookPageService.sync).mockRejectedValue(new Error("no handbook path configured"));
|
|
33
|
+
const refresh = vi.fn().mockResolvedValue(undefined);
|
|
34
|
+
render(<HandbookSyncButton refresh={refresh} />);
|
|
35
|
+
|
|
36
|
+
await userEvent.click(screen.getByRole("button"));
|
|
37
|
+
|
|
38
|
+
await waitFor(() => expect(HandbookPageService.sync).toHaveBeenCalledTimes(1));
|
|
39
|
+
expect(refresh).not.toHaveBeenCalled();
|
|
40
|
+
await waitFor(() => expect(screen.getByRole("button")).not.toBeDisabled());
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default as HandbookAdminContainer } from "./containers/HandbookAdminContainer";
|
|
2
|
+
export { default as HandbookPageContainer } from "./containers/HandbookPageContainer";
|
|
3
|
+
export * from "./containers/HandbookAskContainer";
|
|
4
|
+
export * from "./containers/HandbookAskSheet";
|
|
5
|
+
export * from "./details/HandbookPageDetails";
|
|
6
|
+
export * from "./details/HandbookPageNeighbours";
|
|
7
|
+
export * from "./details/HandbookPageToc";
|
|
8
|
+
export * from "./parts/HandbookPageNavigator";
|
|
9
|
+
export { default as HandbookSyncButton } from "./forms/HandbookSyncButton";
|
|
10
|
+
export { default as HandbookContents } from "./lists/HandbookContents";
|
|
11
|
+
export * from "./lists/HandbookSectionGroup";
|
|
12
|
+
export * from "./parts/HandbookIndexStatus";
|
|
@@ -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
|
+
}
|