@k2b/cloud 0.40.0 → 0.41.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/package.json +6 -3
- package/src/_internal/capability-sentences.ts +2 -1
- package/src/_internal/define-app.ts +13 -3
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/setup-lock.ts +103 -0
- package/src/ai/chat/action-card.ts +69 -3
- package/src/ai/chat/action-question.tsx +12 -1
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- package/src/ai/executor.ts +10 -2
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +15 -0
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/question-text.ts +8 -0
- package/src/ai/routes.ts +51 -5
- package/src/ai/store.ts +18 -0
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +16 -2
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- package/src/api/mcp.ts +27 -3
- package/src/api/me.ts +79 -3
- package/src/approvals/Approvals.tsx +25 -63
- package/src/approvals/approval-card.ts +68 -0
- package/src/contracts/app-approval.ts +3 -3
- package/src/contracts/capabilities.ts +1 -1
- package/src/contracts/shared.ts +3 -0
- package/src/server/middleware/auth.ts +4 -2
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/approvals/device.ts +49 -33
- package/src/services/approvals/index.ts +1 -0
- package/src/services/approvals/list.ts +1 -1
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/index.ts +2 -0
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/session/index.ts +203 -29
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/LayoutHelp.tsx +182 -540
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/help.ts +4 -3
- package/src/ssr/index.ts +1 -0
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-idle.ts +9 -0
- package/src/styles/approvals.css +6 -0
- package/src/styles/effects.css +15 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "../shared/help";
|
|
2
|
+
import { HELP_ACCESS_LEVELS, HELP_KINDS } from "../shared/help";
|
|
3
|
+
import type { HelpPanelState } from "./layout-help-panel-state";
|
|
4
|
+
|
|
5
|
+
const optionalString = (value: object, key: string): string | undefined => {
|
|
6
|
+
const field = Reflect.get(value, key);
|
|
7
|
+
return typeof field === "string" ? field : undefined;
|
|
8
|
+
};
|
|
9
|
+
/** A linked article from the Help API, or nothing when the server sent something else. */
|
|
10
|
+
const articleLink = (value: unknown): HelpArticleLink | undefined => {
|
|
11
|
+
if (!value || typeof value !== "object") return undefined;
|
|
12
|
+
const [appId, appName, documentId, title, url] = ["appId", "appName", "documentId", "title", "url"].map((key) =>
|
|
13
|
+
optionalString(value, key),
|
|
14
|
+
);
|
|
15
|
+
if (!appId || !appName || !documentId || !title || !url?.startsWith("/")) return undefined;
|
|
16
|
+
const [accent, description, icon] = ["accent", "description", "icon"].map((key) => optionalString(value, key));
|
|
17
|
+
return { appId, appName, accent, documentId, title, description, icon, url };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const loadPanelDocument = async (
|
|
21
|
+
state: HelpPanelState,
|
|
22
|
+
topic: HelpDocumentManifest,
|
|
23
|
+
signal: AbortSignal,
|
|
24
|
+
t: { requestFailed: string; invalidDocument: string },
|
|
25
|
+
) => {
|
|
26
|
+
const cacheKey = `${topic.locale ?? "en"}:${topic.url}`;
|
|
27
|
+
const cached = state.cache.get(cacheKey);
|
|
28
|
+
if (cached) return cached;
|
|
29
|
+
|
|
30
|
+
const response = await fetch(topic.url, { signal, headers: { Accept: "application/json" } });
|
|
31
|
+
if (!response.ok) throw new Error(t.requestFailed);
|
|
32
|
+
const value: unknown = await response.json();
|
|
33
|
+
if (
|
|
34
|
+
!value ||
|
|
35
|
+
typeof value !== "object" ||
|
|
36
|
+
!("id" in value) ||
|
|
37
|
+
value.id !== topic.id ||
|
|
38
|
+
!("html" in value) ||
|
|
39
|
+
!("markdown" in value) ||
|
|
40
|
+
!("title" in value) ||
|
|
41
|
+
("locale" in value && value.locale !== undefined && typeof value.locale !== "string") ||
|
|
42
|
+
(topic.locale !== undefined && "locale" in value && value.locale !== undefined && value.locale !== topic.locale) ||
|
|
43
|
+
typeof value.html !== "string" ||
|
|
44
|
+
typeof value.markdown !== "string" ||
|
|
45
|
+
typeof value.title !== "string"
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(t.invalidDocument);
|
|
48
|
+
}
|
|
49
|
+
const kind = Reflect.get(value, "kind");
|
|
50
|
+
const needs = Reflect.get(value, "needs");
|
|
51
|
+
const related = Reflect.get(value, "related");
|
|
52
|
+
const next = articleLink(Reflect.get(value, "next"));
|
|
53
|
+
const document: HelpDocumentPayload = {
|
|
54
|
+
id: topic.id,
|
|
55
|
+
title: value.title,
|
|
56
|
+
html: value.html,
|
|
57
|
+
markdown: value.markdown,
|
|
58
|
+
locale: "locale" in value && typeof value.locale === "string" ? value.locale : (topic.locale ?? "en"),
|
|
59
|
+
// A Core older than the app's Help reader sends no article facts; the article then shows none.
|
|
60
|
+
kind: HELP_KINDS.find((candidate) => candidate === kind) ?? "task",
|
|
61
|
+
needs: HELP_ACCESS_LEVELS.find((level) => level === needs),
|
|
62
|
+
...(next ? { next } : {}),
|
|
63
|
+
related: Array.isArray(related) ? related.flatMap((link) => articleLink(link) ?? []) : [],
|
|
64
|
+
};
|
|
65
|
+
state.storeDocument(topic, document);
|
|
66
|
+
return document;
|
|
67
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const focused = new WeakMap<Element, string>();
|
|
2
|
+
/** Module evaluation precedes SSR's destructive island mount. */
|
|
3
|
+
export function capturePanelFocus() {
|
|
4
|
+
if (typeof document === "undefined") return;
|
|
5
|
+
const element = document.activeElement;
|
|
6
|
+
const wrapper = element?.closest("solid-island");
|
|
7
|
+
if (!wrapper || !(element instanceof HTMLElement)) return;
|
|
8
|
+
const topic = element.dataset.helpTopic;
|
|
9
|
+
const selector =
|
|
10
|
+
topic !== undefined
|
|
11
|
+
? `[data-help-topic="${CSS.escape(topic)}"]`
|
|
12
|
+
: element.matches("[data-help-menu]")
|
|
13
|
+
? "[data-help-menu]"
|
|
14
|
+
: element.matches(".help-head-back")
|
|
15
|
+
? ".help-head-back"
|
|
16
|
+
: undefined;
|
|
17
|
+
if (selector) focused.set(wrapper, selector);
|
|
18
|
+
}
|
|
19
|
+
export function restorePanelFocus(element: Element) {
|
|
20
|
+
const wrapper = element.closest("solid-island");
|
|
21
|
+
const selector = wrapper && focused.get(wrapper);
|
|
22
|
+
if (!wrapper || !selector) return;
|
|
23
|
+
focused.delete(wrapper);
|
|
24
|
+
wrapper.querySelector<HTMLElement>(selector)?.focus({ preventScroll: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One provider reader per document; production island wrappers intentionally omit data-file. */
|
|
28
|
+
export function panelElement(): HTMLElement {
|
|
29
|
+
const element = document.querySelector<HTMLElement>('.help-shell[data-surface="panel"]');
|
|
30
|
+
if (!element) throw new Error("Missing Help panel");
|
|
31
|
+
return element;
|
|
32
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { clipboard } from "@k2b/stdlib/solid";
|
|
2
|
+
import { Dropdown, toast, useLocale } from "@k2b/ui";
|
|
3
|
+
import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
|
|
4
|
+
import { helpMessages } from "./help-messages";
|
|
5
|
+
import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-help-markdown";
|
|
6
|
+
import { loadPanelDocument } from "./layout-help-panel-document";
|
|
7
|
+
import type { HelpPanelState } from "./layout-help-panel-state";
|
|
8
|
+
import { layoutHelpTopicHref } from "./layout-help-url";
|
|
9
|
+
|
|
10
|
+
export type MenuActivation = { key: string; focus: boolean; pointer?: boolean };
|
|
11
|
+
export function HelpPanelMenu(props: { state: HelpPanelState; activation: MenuActivation }) {
|
|
12
|
+
const state = props.state;
|
|
13
|
+
const locale = useLocale();
|
|
14
|
+
const t = () => helpMessages.resolve([locale()]).t;
|
|
15
|
+
const write = (text: string) => navigator.clipboard.writeText(text);
|
|
16
|
+
const writer = clipboard.createWriter({ write, copiedFor: 1800 });
|
|
17
|
+
const allWriter = clipboard.createWriter({ write, copiedFor: 1800 });
|
|
18
|
+
createEffect(() => state.setAnnouncement(writer.wasCopied() ? t().articleCopiedAnnouncement : ""));
|
|
19
|
+
onCleanup(() => state.setAnnouncement(""));
|
|
20
|
+
const [copying, setCopying] = createSignal(false);
|
|
21
|
+
let controller: AbortController | undefined;
|
|
22
|
+
let trigger: HTMLButtonElement | undefined;
|
|
23
|
+
const topic = () => state.data.documents.find((topic) => topic.id === state.activeId());
|
|
24
|
+
const payload = () => {
|
|
25
|
+
const active = topic();
|
|
26
|
+
return active ? state.cachedDocument(active) : undefined;
|
|
27
|
+
};
|
|
28
|
+
const copyArticle = async () => {
|
|
29
|
+
const active = topic();
|
|
30
|
+
const document = payload();
|
|
31
|
+
if (!active || !document) return;
|
|
32
|
+
// Invoke the clipboard writer before the first await: Safari needs the menu item's user activation.
|
|
33
|
+
if (await writer.copy(formatHelpDocumentMarkdown({ ...active, markdown: document.markdown }))) {
|
|
34
|
+
toast.success(t().articleCopied);
|
|
35
|
+
} else
|
|
36
|
+
toast.error(t().copyFailed({ message: writer.error()?.message ?? t().copyFallback }), {
|
|
37
|
+
action: { label: t().tryAgain, onClick: () => void copyArticle() },
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const copyAll = async () => {
|
|
41
|
+
if (!state.data.documents.length || copying()) return;
|
|
42
|
+
controller?.abort();
|
|
43
|
+
const current = new AbortController();
|
|
44
|
+
controller = current;
|
|
45
|
+
setCopying(true);
|
|
46
|
+
const progress = toast(t().preparing, { progress: "indeterminate", duration: 0, iconClass: "ti ti-markdown" });
|
|
47
|
+
try {
|
|
48
|
+
const documents = await Promise.all(
|
|
49
|
+
state.data.documents.map(async (topic) => ({
|
|
50
|
+
...topic,
|
|
51
|
+
markdown: (await loadPanelDocument(state, topic, current.signal, t())).markdown,
|
|
52
|
+
})),
|
|
53
|
+
);
|
|
54
|
+
if (!(await allWriter.copy(formatHelpBundleMarkdown(documents)))) throw allWriter.error() ?? new Error(t().copyFallback);
|
|
55
|
+
progress.dismiss();
|
|
56
|
+
toast.success(t().helpCopied);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
progress.dismiss();
|
|
59
|
+
if (!(error instanceof DOMException && error.name === "AbortError"))
|
|
60
|
+
toast.error(t().copyAllFailed({ message: error instanceof Error ? error.message : t().copyFallback }), {
|
|
61
|
+
action: { label: t().tryAgain, onClick: () => void copyAll() },
|
|
62
|
+
});
|
|
63
|
+
} finally {
|
|
64
|
+
if (controller === current) {
|
|
65
|
+
controller = undefined;
|
|
66
|
+
setCopying(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const [quiet, setQuiet] = createSignal(!!props.activation.pointer);
|
|
71
|
+
onCleanup(() => controller?.abort());
|
|
72
|
+
onMount(() => {
|
|
73
|
+
if (props.activation.focus) trigger?.focus({ preventScroll: true });
|
|
74
|
+
trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: props.activation.key, bubbles: true, cancelable: true }));
|
|
75
|
+
});
|
|
76
|
+
const items = () => [
|
|
77
|
+
{
|
|
78
|
+
label: t().copyAsMarkdown,
|
|
79
|
+
icon: "ti ti-markdown",
|
|
80
|
+
...(copying() || (state.view() === "article" ? !payload() : !state.data.documents.length)
|
|
81
|
+
? { disabled: true as const }
|
|
82
|
+
: { action: () => void (state.view() === "article" ? copyArticle() : copyAll()) }),
|
|
83
|
+
},
|
|
84
|
+
...(state.data.pageBase
|
|
85
|
+
? [
|
|
86
|
+
{
|
|
87
|
+
label: t().openAsPage,
|
|
88
|
+
icon: "ti ti-app-window",
|
|
89
|
+
href: layoutHelpTopicHref(state.data.pageBase, state.view() === "article" ? state.activeId() : null),
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
: []),
|
|
93
|
+
];
|
|
94
|
+
return (
|
|
95
|
+
<Dropdown.Root label={t().moreActions} position="bottom-left" items={items()}>
|
|
96
|
+
<Dropdown.Trigger
|
|
97
|
+
ref={(element) => {
|
|
98
|
+
trigger = element;
|
|
99
|
+
}}
|
|
100
|
+
iconOnly
|
|
101
|
+
label={t().moreActions}
|
|
102
|
+
variant="ghost"
|
|
103
|
+
data-help-menu=""
|
|
104
|
+
// A press on the light trigger counts as a press here: like Dropdown.Trigger's own, its hint stays closed until
|
|
105
|
+
// the pointer leaves.
|
|
106
|
+
tooltip={quiet() ? false : undefined}
|
|
107
|
+
onPointerLeave={() => setQuiet(false)}
|
|
108
|
+
>
|
|
109
|
+
<i class="ti ti-dots" aria-hidden="true" />
|
|
110
|
+
</Dropdown.Trigger>
|
|
111
|
+
</Dropdown.Root>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Keep importOnDemand() from ../browser/reload out: its eager toast import would break the panel bundle budget.
|
|
2
|
+
export const helpPanelModules = {
|
|
3
|
+
menu: () => import("./layout-help-panel-menu"),
|
|
4
|
+
search: () => import("./layout-help-panel-search"),
|
|
5
|
+
reader: () => import("./layout-help-panel-reader"),
|
|
6
|
+
document: () => import("./layout-help-panel-document"),
|
|
7
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Dropdown, LocaleProvider, MarkdownView, TableOfContents, useLocale } from "@k2b/ui";
|
|
2
|
+
import { type Accessor, createMemo, createSignal, onMount } from "solid-js";
|
|
3
|
+
import { render } from "solid-js/web";
|
|
4
|
+
import type { HelpDocumentPayload } from "../shared/help";
|
|
5
|
+
import { helpMessages } from "./help-messages";
|
|
6
|
+
import { helpArticleParts } from "./layout-help-article";
|
|
7
|
+
import type { HelpPanelState } from "./layout-help-panel-state";
|
|
8
|
+
import type { HelpTopic } from "./layout-help-registry";
|
|
9
|
+
import { HelpArticleView } from "./layout-help-views";
|
|
10
|
+
|
|
11
|
+
function Sections(props: {
|
|
12
|
+
sections: { id: string; label: string }[];
|
|
13
|
+
onSection: (id: string) => void;
|
|
14
|
+
active: Accessor<string | null>;
|
|
15
|
+
activation?: { key: string; focused: boolean };
|
|
16
|
+
}) {
|
|
17
|
+
const locale = useLocale();
|
|
18
|
+
const t = () => helpMessages.resolve([locale()]).t;
|
|
19
|
+
let trigger: HTMLButtonElement | undefined;
|
|
20
|
+
onMount(() => {
|
|
21
|
+
if (!props.activation) return;
|
|
22
|
+
if (props.activation.focused) trigger?.focus({ preventScroll: true });
|
|
23
|
+
trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: props.activation.key, bubbles: true, cancelable: true }));
|
|
24
|
+
});
|
|
25
|
+
return (
|
|
26
|
+
<Dropdown.Root
|
|
27
|
+
label={t().onThisPage}
|
|
28
|
+
position="bottom-left"
|
|
29
|
+
items={props.sections.map((section) => ({
|
|
30
|
+
label: section.label,
|
|
31
|
+
choice: "radio" as const,
|
|
32
|
+
checked: () => props.active() === section.id,
|
|
33
|
+
action: () => {
|
|
34
|
+
props.onSection(section.id);
|
|
35
|
+
},
|
|
36
|
+
}))}
|
|
37
|
+
>
|
|
38
|
+
<Dropdown.Trigger
|
|
39
|
+
ref={(element) => {
|
|
40
|
+
trigger = element;
|
|
41
|
+
}}
|
|
42
|
+
label={t().onThisPage}
|
|
43
|
+
size="sm"
|
|
44
|
+
tooltip={false}
|
|
45
|
+
variant="secondary"
|
|
46
|
+
>
|
|
47
|
+
<i class="ti ti-list" aria-hidden="true" />
|
|
48
|
+
<span>{t().onThisPage}</span>
|
|
49
|
+
<i class="ti ti-chevron-down" aria-hidden="true" />
|
|
50
|
+
</Dropdown.Trigger>
|
|
51
|
+
</Dropdown.Root>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
export function HelpPanelReader(props: {
|
|
55
|
+
state: HelpPanelState;
|
|
56
|
+
topic: HelpTopic;
|
|
57
|
+
document: HelpDocumentPayload;
|
|
58
|
+
onSection: (id: string) => void;
|
|
59
|
+
}) {
|
|
60
|
+
const locale = useLocale();
|
|
61
|
+
const t = () => helpMessages.resolve([locale()]).t;
|
|
62
|
+
const parts = createMemo(() => helpArticleParts(props.document.html));
|
|
63
|
+
const [active, setActive] = createSignal<string | null>(parts().sections[0]?.id ?? null);
|
|
64
|
+
let content: HTMLDivElement | undefined;
|
|
65
|
+
return (
|
|
66
|
+
<HelpArticleView
|
|
67
|
+
surface="panel"
|
|
68
|
+
topic={props.topic}
|
|
69
|
+
appId={props.state.data.appId}
|
|
70
|
+
parts={parts()}
|
|
71
|
+
document={props.document}
|
|
72
|
+
Markdown={MarkdownView}
|
|
73
|
+
showTools={parts().sections.length > 0}
|
|
74
|
+
sectionMenu={
|
|
75
|
+
parts().sections.length ? (
|
|
76
|
+
<Sections
|
|
77
|
+
sections={parts().sections}
|
|
78
|
+
active={active}
|
|
79
|
+
onSection={(id) => {
|
|
80
|
+
setActive(id);
|
|
81
|
+
props.onSection(id);
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
) : null
|
|
85
|
+
}
|
|
86
|
+
copyButton={null}
|
|
87
|
+
loading={null}
|
|
88
|
+
failure={null}
|
|
89
|
+
toc={
|
|
90
|
+
<TableOfContents
|
|
91
|
+
items={parts().sections}
|
|
92
|
+
title={t().onThisPage}
|
|
93
|
+
scrollRoot={() => props.state.scroller() ?? undefined}
|
|
94
|
+
contentRoot={() => content}
|
|
95
|
+
onActiveChange={(id) => setActive(id ?? null)}
|
|
96
|
+
/>
|
|
97
|
+
}
|
|
98
|
+
copyAction={null}
|
|
99
|
+
headingRef={() => {}}
|
|
100
|
+
contentRef={(element) => {
|
|
101
|
+
content = element;
|
|
102
|
+
}}
|
|
103
|
+
onBack={props.state.back}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
export function mountPanelSections(
|
|
108
|
+
container: HTMLElement,
|
|
109
|
+
props: { locale: string; state: HelpPanelState; key: string; focused: boolean; onSection: (id: string) => void },
|
|
110
|
+
): () => void {
|
|
111
|
+
const article = container.closest("article");
|
|
112
|
+
const sections = Array.from(article?.querySelectorAll<HTMLAnchorElement>(".help-article-aside a[href^='#']") ?? []).map((link) => ({
|
|
113
|
+
id: decodeURIComponent(link.getAttribute("href")!.slice(1)),
|
|
114
|
+
label: link.textContent ?? "",
|
|
115
|
+
}));
|
|
116
|
+
const [active, setActive] = createSignal<string | null>(sections[0]?.id ?? null);
|
|
117
|
+
const aside = article?.querySelector<HTMLElement>(".help-article-aside-sticky");
|
|
118
|
+
let disposeToc = () => {};
|
|
119
|
+
// The aside is hidden at every panel width. Upgrade its scroll spy only when the section menu needs it.
|
|
120
|
+
if (aside) {
|
|
121
|
+
aside.textContent = "";
|
|
122
|
+
disposeToc = render(
|
|
123
|
+
() => (
|
|
124
|
+
<LocaleProvider locale={props.locale}>
|
|
125
|
+
<TableOfContents
|
|
126
|
+
items={sections}
|
|
127
|
+
title={helpMessages.resolve([props.locale]).t.onThisPage}
|
|
128
|
+
scrollRoot={() => props.state.scroller() ?? undefined}
|
|
129
|
+
contentRoot={() => article ?? undefined}
|
|
130
|
+
onActiveChange={(id) => setActive(id ?? null)}
|
|
131
|
+
/>
|
|
132
|
+
</LocaleProvider>
|
|
133
|
+
),
|
|
134
|
+
aside,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
container.textContent = "";
|
|
138
|
+
container.style.display = "contents";
|
|
139
|
+
const disposeMenu = render(
|
|
140
|
+
() => (
|
|
141
|
+
<LocaleProvider locale={props.locale}>
|
|
142
|
+
<Sections
|
|
143
|
+
sections={sections}
|
|
144
|
+
active={active}
|
|
145
|
+
onSection={(id) => {
|
|
146
|
+
setActive(id);
|
|
147
|
+
props.onSection(id);
|
|
148
|
+
}}
|
|
149
|
+
activation={{ key: props.key, focused: props.focused }}
|
|
150
|
+
/>
|
|
151
|
+
</LocaleProvider>
|
|
152
|
+
),
|
|
153
|
+
container,
|
|
154
|
+
);
|
|
155
|
+
return () => {
|
|
156
|
+
disposeMenu();
|
|
157
|
+
disposeToc();
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useLocale } from "@k2b/ui";
|
|
2
|
+
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js";
|
|
3
|
+
import { helpMessages } from "./help-messages";
|
|
4
|
+
import type { HelpPanelState } from "./layout-help-panel-state";
|
|
5
|
+
import { searchHelpTopics } from "./layout-help-search";
|
|
6
|
+
import { HelpResultsView } from "./layout-help-views";
|
|
7
|
+
|
|
8
|
+
export function HelpPanelSearchResults(props: { state: HelpPanelState }) {
|
|
9
|
+
const locale = useLocale();
|
|
10
|
+
const t = () => helpMessages.resolve([locale()]).t;
|
|
11
|
+
const query = createMemo(() => props.state.query().trim().toLocaleLowerCase());
|
|
12
|
+
const [ids, setIds] = createSignal<readonly string[] | null>(null);
|
|
13
|
+
const [searching, setSearching] = createSignal(false);
|
|
14
|
+
const [failed, setFailed] = createSignal(false);
|
|
15
|
+
const [attempt, setAttempt] = createSignal(0);
|
|
16
|
+
let version = 0;
|
|
17
|
+
createEffect(() => {
|
|
18
|
+
const value = query();
|
|
19
|
+
attempt();
|
|
20
|
+
const current = ++version;
|
|
21
|
+
setIds(null);
|
|
22
|
+
setSearching(false);
|
|
23
|
+
setFailed(false);
|
|
24
|
+
const urls = [...new Set(props.state.data.documents.map((topic) => topic.searchUrl))];
|
|
25
|
+
if (!value || !urls.length) return;
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
setSearching(true);
|
|
29
|
+
void Promise.all(
|
|
30
|
+
urls.map(async (url) => {
|
|
31
|
+
const response = await fetch(`${url}?q=${encodeURIComponent(value)}`, {
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
headers: { Accept: "application/json" },
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) throw new Error(t().searchFailed);
|
|
36
|
+
const payload: unknown = await response.json();
|
|
37
|
+
return payload && typeof payload === "object" && "ids" in payload && Array.isArray(payload.ids)
|
|
38
|
+
? payload.ids.filter((id): id is string => typeof id === "string")
|
|
39
|
+
: [];
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
.then((groups) => {
|
|
43
|
+
if (current === version) setIds([...new Set(groups.flat())]);
|
|
44
|
+
})
|
|
45
|
+
.catch((error) => {
|
|
46
|
+
if (!(error instanceof DOMException && error.name === "AbortError") && current === version) setFailed(true);
|
|
47
|
+
})
|
|
48
|
+
.finally(() => {
|
|
49
|
+
if (current === version) setSearching(false);
|
|
50
|
+
});
|
|
51
|
+
}, 120);
|
|
52
|
+
onCleanup(() => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
controller.abort();
|
|
55
|
+
if (current === version) version++;
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
return (
|
|
59
|
+
<Show when={query()}>
|
|
60
|
+
<HelpResultsView
|
|
61
|
+
results={searchHelpTopics(props.state.data.documents, query(), ids())}
|
|
62
|
+
searching={searching()}
|
|
63
|
+
failed={failed()}
|
|
64
|
+
onRetry={() => setAttempt((value) => value + 1)}
|
|
65
|
+
onOpen={() => {}}
|
|
66
|
+
/>
|
|
67
|
+
</Show>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getContext } from "../browser/panel";
|
|
2
|
+
import { panelShortcutsReceived, readPanelShortcuts } from "../browser/panel-shortcuts";
|
|
3
|
+
import type { HelpShortcutCommand } from "./layout-help-start";
|
|
4
|
+
|
|
5
|
+
/** An empty host list is authoritative too, including when a reader remounts. */
|
|
6
|
+
export const readHelpPanelShortcuts = (seed: HelpShortcutCommand[]): HelpShortcutCommand[] =>
|
|
7
|
+
panelShortcutsReceived() && getContext() !== null ? readPanelShortcuts() : seed;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { batch, createSignal } from "solid-js";
|
|
2
|
+
import type { HelpDocumentManifest, HelpDocumentPayload } from "../shared/help";
|
|
3
|
+
import { focusHelpArticleHeading } from "./layout-help-navigation";
|
|
4
|
+
|
|
5
|
+
export type HelpPanelData = {
|
|
6
|
+
documents: HelpDocumentManifest[];
|
|
7
|
+
appId?: string;
|
|
8
|
+
pageBase?: string;
|
|
9
|
+
article?: Omit<HelpDocumentPayload, "html">;
|
|
10
|
+
};
|
|
11
|
+
const states = new WeakMap<Element, ReturnType<typeof createPanelState>>();
|
|
12
|
+
export const panelRoot = (element: Element): HTMLElement => {
|
|
13
|
+
const root = element.closest<HTMLElement>('.help-shell[data-surface="panel"]');
|
|
14
|
+
if (!root) throw new Error("Missing Help panel");
|
|
15
|
+
return root;
|
|
16
|
+
};
|
|
17
|
+
const createPanelState = (root: HTMLElement) => {
|
|
18
|
+
// This is our own server's inert JSON, shared once instead of repeating it in island props.
|
|
19
|
+
const data: HelpPanelData = JSON.parse(root.querySelector("[data-help-panel-data]")?.textContent ?? "{}");
|
|
20
|
+
const [view, setView] = createSignal<"hub" | "article">(root.dataset.helpInitialTopic ? "article" : "hub");
|
|
21
|
+
const [activeId, setActiveId] = createSignal<string | null>(data.article?.id ?? root.dataset.helpInitialTopic ?? null);
|
|
22
|
+
const [query, setQuery] = createSignal("");
|
|
23
|
+
const [searchReturnId, setSearchReturnId] = createSignal<string | null>(null);
|
|
24
|
+
const [navigation, setNavigation] = createSignal({ hash: "", version: 0 });
|
|
25
|
+
const [announcement, setAnnouncement] = createSignal("");
|
|
26
|
+
const cache = new Map<string, HelpDocumentPayload>();
|
|
27
|
+
const [cacheVersion, setCacheVersion] = createSignal(0);
|
|
28
|
+
const cacheKey = (topic: HelpDocumentManifest) => `${topic.locale ?? "en"}:${topic.url}`;
|
|
29
|
+
if (data.article) {
|
|
30
|
+
const topic = data.documents.find((topic) => topic.id === data.article?.id);
|
|
31
|
+
if (topic) cache.set(cacheKey(topic), { ...data.article, html: "" });
|
|
32
|
+
}
|
|
33
|
+
const cachedDocument = (topic: HelpDocumentManifest) => {
|
|
34
|
+
cacheVersion();
|
|
35
|
+
return cache.get(cacheKey(topic));
|
|
36
|
+
};
|
|
37
|
+
const storeDocument = (topic: HelpDocumentManifest, payload: HelpDocumentPayload) => {
|
|
38
|
+
cache.set(cacheKey(topic), payload);
|
|
39
|
+
setCacheVersion((value) => value + 1);
|
|
40
|
+
};
|
|
41
|
+
const results = () => root.querySelector<HTMLElement>("[data-help-results]");
|
|
42
|
+
const scroller = () => root.querySelector<HTMLElement>("[data-scroll-fade-mode]");
|
|
43
|
+
const sync = () => {
|
|
44
|
+
const hub = view() === "hub";
|
|
45
|
+
const start = results();
|
|
46
|
+
if (start) start.hidden = !hub;
|
|
47
|
+
const server = root.querySelector<HTMLElement>("[data-help-server-article]");
|
|
48
|
+
if (server) server.hidden = hub || activeId() !== data.article?.id;
|
|
49
|
+
};
|
|
50
|
+
const back = () => {
|
|
51
|
+
setSearchReturnId(null);
|
|
52
|
+
setView("hub");
|
|
53
|
+
sync();
|
|
54
|
+
};
|
|
55
|
+
/** Where the article stood when a search started from it; clearing the search returns there. */
|
|
56
|
+
let articleScrollTop = 0;
|
|
57
|
+
const changeQuery = (value: string) => {
|
|
58
|
+
const searching = !!value.trim();
|
|
59
|
+
let returned = false;
|
|
60
|
+
batch(() => {
|
|
61
|
+
if (searching && view() === "article") {
|
|
62
|
+
articleScrollTop = scroller()?.scrollTop ?? 0;
|
|
63
|
+
setSearchReturnId(activeId());
|
|
64
|
+
const region = results();
|
|
65
|
+
if (region) region.style.minHeight = "";
|
|
66
|
+
setView("hub");
|
|
67
|
+
}
|
|
68
|
+
setQuery(value);
|
|
69
|
+
if (!searching) {
|
|
70
|
+
const region = results();
|
|
71
|
+
if (region) region.style.minHeight = "";
|
|
72
|
+
const id = searchReturnId();
|
|
73
|
+
setSearchReturnId(null);
|
|
74
|
+
if (id && data.documents.some((topic) => topic.id === id)) {
|
|
75
|
+
setActiveId(id);
|
|
76
|
+
setView("article");
|
|
77
|
+
returned = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
sync();
|
|
82
|
+
// The article stayed in the document while results showed; give it back its place once it has its height again.
|
|
83
|
+
if (returned) {
|
|
84
|
+
const top = articleScrollTop;
|
|
85
|
+
requestAnimationFrame(() => {
|
|
86
|
+
const scroll = scroller();
|
|
87
|
+
if (scroll) scroll.scrollTop = top;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const open = (id: string, hash = "") => {
|
|
92
|
+
if (!data.documents.some((topic) => topic.id === id)) return;
|
|
93
|
+
try {
|
|
94
|
+
localStorage.setItem("cloud.layoutHelp.activeTopic", id);
|
|
95
|
+
} catch {
|
|
96
|
+
/* Storage is optional. */
|
|
97
|
+
}
|
|
98
|
+
batch(() => {
|
|
99
|
+
setSearchReturnId(null);
|
|
100
|
+
setActiveId(id);
|
|
101
|
+
setView("article");
|
|
102
|
+
setNavigation((value) => ({ hash, version: value.version + 1 }));
|
|
103
|
+
});
|
|
104
|
+
const scroll = scroller();
|
|
105
|
+
if (scroll) scroll.scrollTop = 0;
|
|
106
|
+
sync();
|
|
107
|
+
};
|
|
108
|
+
const focusHeading = () =>
|
|
109
|
+
focusHelpArticleHeading(root.querySelector<HTMLHeadingElement>("article:not([hidden]) .help-article-title") ?? undefined);
|
|
110
|
+
return {
|
|
111
|
+
root,
|
|
112
|
+
data,
|
|
113
|
+
view,
|
|
114
|
+
activeId,
|
|
115
|
+
query,
|
|
116
|
+
changeQuery,
|
|
117
|
+
back,
|
|
118
|
+
open,
|
|
119
|
+
navigation,
|
|
120
|
+
cache,
|
|
121
|
+
cacheKey,
|
|
122
|
+
cachedDocument,
|
|
123
|
+
storeDocument,
|
|
124
|
+
sync,
|
|
125
|
+
results,
|
|
126
|
+
scroller,
|
|
127
|
+
focusHeading,
|
|
128
|
+
announcement,
|
|
129
|
+
setAnnouncement,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
export type HelpPanelState = ReturnType<typeof createPanelState>;
|
|
133
|
+
export function helpPanelState(element: Element): HelpPanelState {
|
|
134
|
+
const root = panelRoot(element);
|
|
135
|
+
let state = states.get(root);
|
|
136
|
+
if (!state) {
|
|
137
|
+
state = createPanelState(root);
|
|
138
|
+
states.set(root, state);
|
|
139
|
+
}
|
|
140
|
+
return state;
|
|
141
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HelpDocumentManifest } from "@k2b/cloud/shared";
|
|
2
|
+
import type { KeyboardPlatform } from "@k2b/ui";
|
|
3
|
+
import type { LayoutHelpArticle } from "./help";
|
|
4
|
+
|
|
5
|
+
export type LayoutHelpPanelProps = {
|
|
6
|
+
documents: readonly HelpDocumentManifest[];
|
|
7
|
+
/** Absent when the context selected Help the reader may not see. */
|
|
8
|
+
app?: { id: string; name: string; icon?: string };
|
|
9
|
+
/** appAppearanceStyle() of the app, from the server. */
|
|
10
|
+
appearance?: string;
|
|
11
|
+
/** Server-filtered For this view ids. */
|
|
12
|
+
view?: readonly string[];
|
|
13
|
+
initialTopic?: string;
|
|
14
|
+
/** Authorized article and server-parsed parts for an identical first frame. */
|
|
15
|
+
initialArticle?: LayoutHelpArticle;
|
|
16
|
+
pageBase?: string;
|
|
17
|
+
platform: KeyboardPlatform;
|
|
18
|
+
state?: "failed" | "unavailable";
|
|
19
|
+
};
|