@k2b/cloud 0.32.0 → 0.34.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 +5 -5
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +18 -8
- package/src/_internal/page-responses.ts +5 -1
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +8 -5
- package/src/ai/code-source-contracts.ts +17 -2
- package/src/ai/code-source-tools.ts +1 -0
- package/src/ai/default-tools.ts +4 -4
- package/src/ai/memories.ts +48 -1
- package/src/ai/memory-learning.ts +17 -6
- package/src/ai/memory-tool.ts +1 -0
- package/src/ai/migrate.ts +8 -0
- package/src/ai/provider.ts +4 -3
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/navigation-search.ts +4 -1
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/outbox.ts +13 -6
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/ai-platform-prompt.ts +3 -2
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/shared/theme.ts +41 -20
- package/src/ssr/AnnouncementBanners.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +91 -0
- package/src/ssr/GlobalSearchTrigger.tsx +51 -0
- package/src/ssr/Layout.tsx +58 -99
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +319 -238
- package/src/ssr/LayoutHelpTrigger.tsx +49 -0
- package/src/ssr/LayoutPreferences.tsx +31 -0
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +6 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +4 -6
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +8 -5
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/ShellRuntime.island.tsx +166 -0
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/TimezoneCookie.island.tsx +3 -19
- package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
- package/src/ssr/announcements-dialog.tsx +66 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
- package/src/ssr/busy-action.ts +19 -0
- package/src/ssr/islands/index.ts +9 -6
- package/src/ssr/launchpad-surface.tsx +15 -0
- package/src/ssr/layout-help-registry.tsx +60 -0
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +62 -19
- package/src/ssr/profile-actions.ts +12 -5
- package/src/ssr/profile-preferences-messages.ts +10 -4
- package/src/ssr/rail-context.ts +8 -12
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +48 -0
- package/src/ssr/timezone-cookie.ts +22 -0
- package/src/ssr/workspace-navigation.ts +25 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +43 -5
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -0
- package/src/workflows/language/compiler.ts +1 -1
- package/src/ssr/AppWorkspaceController.island.tsx +0 -21
- package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
- package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
- package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- package/src/ssr/LayoutPreferences.island.tsx +0 -21
- package/src/ssr/PageTrailRow.island.tsx +0 -20
- package/src/ssr/PageTrailRuntime.island.tsx +0 -26
- package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
- /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
- /package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +0 -0
|
@@ -5,12 +5,31 @@ import { provideWorkspaceNavigation } from "./workspace-navigation";
|
|
|
5
5
|
export default function WorkspaceNavigationProvider(props: { navigation: NavigationController; label: string }) {
|
|
6
6
|
let owner!: HTMLScriptElement;
|
|
7
7
|
provideWorkspaceNavigation(props.navigation, { label: () => props.label, owner: () => owner });
|
|
8
|
+
const snapshot = () => {
|
|
9
|
+
const switcher = props.navigation.switcher?.();
|
|
10
|
+
return {
|
|
11
|
+
label: props.label,
|
|
12
|
+
items: props.navigation.items(),
|
|
13
|
+
...(switcher
|
|
14
|
+
? {
|
|
15
|
+
switcher: {
|
|
16
|
+
currentId: switcher.currentId,
|
|
17
|
+
label: switcher.label,
|
|
18
|
+
description: switcher.description,
|
|
19
|
+
icon: switcher.icon,
|
|
20
|
+
color: switcher.color,
|
|
21
|
+
switchLabel: switcher.switchLabel,
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
: {}),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
8
27
|
return (
|
|
9
28
|
<script
|
|
10
29
|
ref={owner}
|
|
11
30
|
type="application/json"
|
|
12
31
|
data-cloud-workspace-navigation
|
|
13
|
-
innerHTML={JSON.stringify(
|
|
32
|
+
innerHTML={JSON.stringify(snapshot()).replace(/</g, "\\u003c")}
|
|
14
33
|
/>
|
|
15
34
|
);
|
|
16
35
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Button, dialogCore, MarkdownView, PanelDialog, panelDialogOptions, useLocale } from "@k2b/ui";
|
|
2
|
+
import { For } from "solid-js";
|
|
3
|
+
import {
|
|
4
|
+
ANNOUNCEMENTS_COOKIE,
|
|
5
|
+
ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
|
|
6
|
+
mergeAnnouncementCookieState,
|
|
7
|
+
parseAnnouncementCookieHeader,
|
|
8
|
+
serializeAnnouncementCookieState,
|
|
9
|
+
} from "../contracts/announcement-cookie";
|
|
10
|
+
import type { AnnouncementDisplayEntry } from "../contracts/announcements";
|
|
11
|
+
import { platformMessages } from "./platform-messages";
|
|
12
|
+
|
|
13
|
+
function AnnouncementsPanel(props: { entries: AnnouncementDisplayEntry[]; close: () => void }) {
|
|
14
|
+
const locale = useLocale();
|
|
15
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
16
|
+
return (
|
|
17
|
+
<PanelDialog>
|
|
18
|
+
<PanelDialog.Header
|
|
19
|
+
title={t().announcements}
|
|
20
|
+
subtitle={t().latestPlatformUpdates}
|
|
21
|
+
icon="ti ti-speakerphone text-blue-500"
|
|
22
|
+
close={props.close}
|
|
23
|
+
/>
|
|
24
|
+
<PanelDialog.Body>
|
|
25
|
+
<For each={props.entries}>
|
|
26
|
+
{(entry) => (
|
|
27
|
+
<article class="panel-dialog-section rounded-[var(--ui-radius-surface)] p-4">
|
|
28
|
+
<div class="mb-3 flex items-start justify-between gap-3">
|
|
29
|
+
<div class="min-w-0">
|
|
30
|
+
<h2 class="text-base font-semibold text-primary">{entry.title}</h2>
|
|
31
|
+
<p class="mt-0.5 text-xs text-dimmed">
|
|
32
|
+
{new Date(entry.publishedAt).toLocaleDateString(locale(), {
|
|
33
|
+
year: "numeric",
|
|
34
|
+
month: "short",
|
|
35
|
+
day: "numeric",
|
|
36
|
+
})}
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
<span class="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-dimmed dark:bg-zinc-800">v{entry.version}</span>
|
|
40
|
+
</div>
|
|
41
|
+
<MarkdownView trustedHtml={entry.bodyHtml} />
|
|
42
|
+
</article>
|
|
43
|
+
)}
|
|
44
|
+
</For>
|
|
45
|
+
</PanelDialog.Body>
|
|
46
|
+
<PanelDialog.Footer>
|
|
47
|
+
<span />
|
|
48
|
+
<Button size="sm" onClick={props.close}>
|
|
49
|
+
{t().gotIt}
|
|
50
|
+
</Button>
|
|
51
|
+
</PanelDialog.Footer>
|
|
52
|
+
</PanelDialog>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shows the announcements the person has not seen yet, and records them as seen once the dialog closes. */
|
|
57
|
+
export function openAnnouncementsDialog(props: { entries: AnnouncementDisplayEntry[]; latestVersion: number }) {
|
|
58
|
+
void dialogCore
|
|
59
|
+
.open<void>((close) => <AnnouncementsPanel entries={props.entries} close={() => close()} />, panelDialogOptions)
|
|
60
|
+
.then(() => {
|
|
61
|
+
const next = mergeAnnouncementCookieState(parseAnnouncementCookieHeader(document.cookie), {
|
|
62
|
+
seenAnnouncementVersion: props.latestVersion,
|
|
63
|
+
});
|
|
64
|
+
document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(next)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AppAppearance } from "../contracts/app";
|
|
2
2
|
import { resolveRuntimeRoute } from "./app-navigation";
|
|
3
|
+
import { helpPageAppId } from "./layout-help-url";
|
|
3
4
|
import type { RuntimeContext } from "./runtime";
|
|
4
5
|
|
|
5
6
|
const isHexColor = (value: string | undefined): value is `#${string}` => /^#[0-9a-f]{6}$/i.test(value ?? "");
|
|
@@ -20,4 +21,8 @@ export const appAppearanceStyle = (appearance: AppAppearance | undefined): strin
|
|
|
20
21
|
export const appAccentStyle = (accent: string | undefined): string | undefined =>
|
|
21
22
|
isHexColor(accent) ? `--app-accent:${accent}` : undefined;
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/** Core's full-page Help of an app belongs to that app. */
|
|
25
|
+
export const resolveCurrentApp = (apps: RuntimeContext["apps"], pathname: string) => {
|
|
26
|
+
const helpAppId = helpPageAppId(pathname);
|
|
27
|
+
return apps.find((app) => app.id === helpAppId) ?? resolveRuntimeRoute(apps, pathname)?.app;
|
|
28
|
+
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { openCloudMobileMenu } from "./MobileNavigation";
|
|
3
|
-
|
|
4
|
-
export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "./AppLaunchpadPanel";
|
|
5
|
-
|
|
6
|
-
import { dialogCore, IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
1
|
+
import { IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
7
2
|
import { createEffect } from "solid-js";
|
|
3
|
+
import { importOnDemand } from "../browser/reload";
|
|
4
|
+
import type { AppLaunchpadApp, AppLaunchpadContext, AppLaunchpadLegalLink } from "./AppLaunchpadPanel";
|
|
5
|
+
import { createBusyAction } from "./busy-action";
|
|
8
6
|
import { platformMessages } from "./platform-messages";
|
|
7
|
+
import { readShellData } from "./shell-data";
|
|
8
|
+
|
|
9
|
+
export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "./AppLaunchpadPanel";
|
|
9
10
|
|
|
10
11
|
declare global {
|
|
11
12
|
interface Window {
|
|
@@ -17,29 +18,15 @@ declare global {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
const readEmbeddedContext = (): AppLaunchpadContext | undefined => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
apps: parsed.apps,
|
|
30
|
-
profile:
|
|
31
|
-
parsed.profile?.name && (parsed.profile.theme === "dark" || parsed.profile.theme === "light")
|
|
32
|
-
? {
|
|
33
|
-
name: parsed.profile.name,
|
|
34
|
-
theme: parsed.profile.theme,
|
|
35
|
-
...(parsed.profile.appHref === "/me/app" ? { appHref: parsed.profile.appHref } : {}),
|
|
36
|
-
}
|
|
37
|
-
: undefined,
|
|
38
|
-
legalLinks: Array.isArray(parsed.legalLinks) ? parsed.legalLinks : [],
|
|
39
|
-
};
|
|
40
|
-
} catch {
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
21
|
+
const data = readShellData();
|
|
22
|
+
if (!data) return undefined;
|
|
23
|
+
return {
|
|
24
|
+
apps: data.apps,
|
|
25
|
+
profile: data.profile
|
|
26
|
+
? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref === "/me/app" ? { appHref: data.profile.appHref } : {}) }
|
|
27
|
+
: undefined,
|
|
28
|
+
legalLinks: data.legalLinks,
|
|
29
|
+
};
|
|
43
30
|
};
|
|
44
31
|
|
|
45
32
|
export function setAppLaunchpadContext(
|
|
@@ -55,20 +42,20 @@ export function setAppLaunchpadContext(
|
|
|
55
42
|
};
|
|
56
43
|
}
|
|
57
44
|
|
|
45
|
+
/** Every caller of the public opener shares one load, so a double click opens one dialog. */
|
|
46
|
+
let launchpadPending: Promise<void> | undefined;
|
|
47
|
+
|
|
58
48
|
export function openAppLaunchpad(apps?: AppLaunchpadApp[], legalLinks?: AppLaunchpadLegalLink[], profile?: AppLaunchpadContext["profile"]) {
|
|
59
|
-
if (typeof window === "undefined") return;
|
|
49
|
+
if (typeof window === "undefined" || launchpadPending) return;
|
|
60
50
|
const context = apps ? { apps, legalLinks: legalLinks ?? [], profile } : (window.__cloudAppLaunchpad ?? readEmbeddedContext());
|
|
61
51
|
if (!context) return;
|
|
62
52
|
window.__cloudAppLaunchpad = context;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
contentClassName: "k2b-dialog__viewport is-bare",
|
|
70
|
-
initialFocus: (dialog) => dialog.querySelector<HTMLAnchorElement>("a[href]"),
|
|
71
|
-
});
|
|
53
|
+
launchpadPending = importOnDemand(() => import("./launchpad-surface"))
|
|
54
|
+
.then((loaded) => loaded?.openLaunchpadSurface(context))
|
|
55
|
+
.finally(() => {
|
|
56
|
+
launchpadPending = undefined;
|
|
57
|
+
});
|
|
58
|
+
return launchpadPending;
|
|
72
59
|
}
|
|
73
60
|
|
|
74
61
|
export function AppLaunchpadProvider(props: AppLaunchpadContext) {
|
|
@@ -82,7 +69,16 @@ export function AppLaunchpadProvider(props: AppLaunchpadContext) {
|
|
|
82
69
|
export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail" | "header" | "menu"; label?: string }) {
|
|
83
70
|
const locale = useLocale();
|
|
84
71
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
85
|
-
const
|
|
72
|
+
const action = createBusyAction();
|
|
73
|
+
const open = () =>
|
|
74
|
+
action.run(
|
|
75
|
+
() => importOnDemand(() => import("./launchpad-surface")),
|
|
76
|
+
(loaded) => {
|
|
77
|
+
const context = { apps: props.apps, legalLinks: props.legalLinks, profile: props.profile };
|
|
78
|
+
window.__cloudAppLaunchpad = context;
|
|
79
|
+
loaded.openLaunchpadSurface(context);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
86
82
|
|
|
87
83
|
if (props.variant === "rail") {
|
|
88
84
|
return (
|
|
@@ -95,16 +91,17 @@ export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail
|
|
|
95
91
|
delay={0}
|
|
96
92
|
aria-label={props.label ?? t().openApps}
|
|
97
93
|
onClick={open}
|
|
94
|
+
aria-busy={action.busy() ? "true" : undefined}
|
|
98
95
|
>
|
|
99
|
-
<i class="ti ti-grid-dots text-base
|
|
96
|
+
<i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-grid-dots"} text-base`} aria-hidden="true" />
|
|
100
97
|
</Tooltip.Trigger>
|
|
101
98
|
);
|
|
102
99
|
}
|
|
103
100
|
|
|
104
101
|
if (props.variant === "header") {
|
|
105
102
|
return (
|
|
106
|
-
<IconButton label={props.label ?? t().openApps} onClick={open}>
|
|
107
|
-
<i class="ti ti-grid-dots text-lg
|
|
103
|
+
<IconButton label={props.label ?? t().openApps} onClick={open} aria-busy={action.busy() ? "true" : undefined}>
|
|
104
|
+
<i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-grid-dots"} text-lg`} />
|
|
108
105
|
</IconButton>
|
|
109
106
|
);
|
|
110
107
|
}
|
|
@@ -114,27 +111,10 @@ export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail
|
|
|
114
111
|
type="button"
|
|
115
112
|
class="flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10 text-zinc-700 dark:text-zinc-300"
|
|
116
113
|
onClick={open}
|
|
114
|
+
aria-busy={action.busy() ? "true" : undefined}
|
|
117
115
|
>
|
|
118
|
-
<i class="ti ti-grid-dots" />
|
|
116
|
+
<i class={action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-grid-dots"} />
|
|
119
117
|
<span>{props.label ?? t().apps}</span>
|
|
120
118
|
</button>
|
|
121
119
|
);
|
|
122
120
|
}
|
|
123
|
-
|
|
124
|
-
export function AppLaunchpad(props: AppLaunchpadContext & { variant?: "provider" | "rail" | "header" | "menu"; label?: string }) {
|
|
125
|
-
if (!props.variant || props.variant === "provider") {
|
|
126
|
-
return <AppLaunchpadProvider apps={props.apps} legalLinks={props.legalLinks} profile={props.profile} />;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return (
|
|
130
|
-
<AppLaunchpadButton
|
|
131
|
-
apps={props.apps}
|
|
132
|
-
legalLinks={props.legalLinks}
|
|
133
|
-
profile={props.profile}
|
|
134
|
-
variant={props.variant}
|
|
135
|
-
label={props.label}
|
|
136
|
-
/>
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export default AppLaunchpad;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
|
|
3
|
+
/** Keep the trigger focused and ignore repeated clicks while its code loads. */
|
|
4
|
+
export function createBusyAction() {
|
|
5
|
+
const [busy, setBusy] = createSignal(false);
|
|
6
|
+
return {
|
|
7
|
+
busy,
|
|
8
|
+
async run<T>(load: () => Promise<T | undefined>, open: (loaded: T) => void) {
|
|
9
|
+
if (busy()) return;
|
|
10
|
+
setBusy(true);
|
|
11
|
+
try {
|
|
12
|
+
const loaded = await load();
|
|
13
|
+
if (loaded !== undefined) open(loaded);
|
|
14
|
+
} finally {
|
|
15
|
+
setBusy(false);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
package/src/ssr/islands/index.ts
CHANGED
|
@@ -3,13 +3,16 @@ export { default as WorkspaceNavigationProvider } from "../WorkspaceNavigationPr
|
|
|
3
3
|
export { provideWorkspaceNavigation } from "../workspace-navigation";
|
|
4
4
|
export { default as SearchBar } from "./SearchBar";
|
|
5
5
|
|
|
6
|
-
import { AppLaunchpadButton, AppLaunchpadProvider } from "../
|
|
7
|
-
import {
|
|
6
|
+
import { AppLaunchpadButton, AppLaunchpadProvider } from "../app-launchpad";
|
|
7
|
+
import { LayoutHelpPage } from "../LayoutHelp";
|
|
8
|
+
import { LayoutHelpDocuments } from "../layout-help-registry";
|
|
8
9
|
|
|
9
|
-
export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "../
|
|
10
|
-
export { AppLaunchpadButton, AppLaunchpadProvider, openAppLaunchpad, setAppLaunchpadContext } from "../
|
|
11
|
-
export type {
|
|
12
|
-
export {
|
|
10
|
+
export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "../app-launchpad";
|
|
11
|
+
export { AppLaunchpadButton, AppLaunchpadProvider, openAppLaunchpad, setAppLaunchpadContext } from "../app-launchpad";
|
|
12
|
+
export type { LayoutHelpPageProps } from "../LayoutHelp";
|
|
13
|
+
export { LayoutHelpPage, openLayoutHelpDialog } from "../LayoutHelp";
|
|
14
|
+
export type { LayoutHelpDocumentsProps } from "../layout-help-registry";
|
|
15
|
+
export { LayoutHelpDocuments } from "../layout-help-registry";
|
|
13
16
|
|
|
14
17
|
export const Layout = {
|
|
15
18
|
HelpDocuments: LayoutHelpDocuments,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { dialogCore } from "@k2b/ui";
|
|
2
|
+
import { type AppLaunchpadContext, AppLaunchpadPanel } from "./AppLaunchpadPanel";
|
|
3
|
+
import { openCloudMobileMenu } from "./MobileNavigation";
|
|
4
|
+
|
|
5
|
+
export function openLaunchpadSurface(context: AppLaunchpadContext) {
|
|
6
|
+
if (window.matchMedia("(max-width: 767px)").matches) {
|
|
7
|
+
void openCloudMobileMenu(context);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
void dialogCore.open<void>((close) => <AppLaunchpadPanel apps={context.apps} legalLinks={context.legalLinks} close={close} />, {
|
|
11
|
+
panelClassName: "k2b-dialog k2b-dialog--large is-bare",
|
|
12
|
+
contentClassName: "k2b-dialog__viewport is-bare",
|
|
13
|
+
initialFocus: (dialog) => dialog.querySelector<HTMLAnchorElement>("a[href]"),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { HelpDocumentManifest } from "@k2b/cloud/shared";
|
|
2
|
+
import { type JSX, onCleanup, onMount } from "solid-js";
|
|
3
|
+
|
|
4
|
+
type HelpTopicBase = {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
icon?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
order?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type LayoutHelpDocumentsProps = {
|
|
13
|
+
documents: readonly HelpDocumentManifest[];
|
|
14
|
+
/** Canonical standalone Help route owned by the current app. */
|
|
15
|
+
pageBase: string;
|
|
16
|
+
};
|
|
17
|
+
export type HelpTopic = (HelpTopicBase & { kind: "content"; children: JSX.Element }) | (HelpDocumentManifest & { kind: "document" });
|
|
18
|
+
|
|
19
|
+
export const HELP_TOPICS_EVENT = "cloud:layout-help-topics";
|
|
20
|
+
|
|
21
|
+
declare global {
|
|
22
|
+
interface Window {
|
|
23
|
+
__cloudLayoutHelpTopics?: Map<string, HelpTopic>;
|
|
24
|
+
__cloudLayoutHelpPageBase?: string;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const registry = () => {
|
|
29
|
+
if (typeof window === "undefined") return null;
|
|
30
|
+
window.__cloudLayoutHelpTopics ??= new Map<string, HelpTopic>();
|
|
31
|
+
return window.__cloudLayoutHelpTopics;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const emitTopicsChanged = () => window.dispatchEvent(new Event(HELP_TOPICS_EVENT));
|
|
35
|
+
const registerTopic = (topic: HelpTopic) => {
|
|
36
|
+
const topics = registry();
|
|
37
|
+
if (!topics) return () => {};
|
|
38
|
+
topics.set(topic.id, topic);
|
|
39
|
+
emitTopicsChanged();
|
|
40
|
+
return () => {
|
|
41
|
+
if (topics.get(topic.id) !== topic) return;
|
|
42
|
+
topics.delete(topic.id);
|
|
43
|
+
emitTopicsChanged();
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Register an app's server-prepared Markdown manifest with the shared help UI. */
|
|
48
|
+
export function LayoutHelpDocuments(props: LayoutHelpDocumentsProps) {
|
|
49
|
+
onMount(() => {
|
|
50
|
+
// Cleanup runs while this unmounts, when a parent `<Show>` may already have dropped the props' source.
|
|
51
|
+
const pageBase = props.pageBase;
|
|
52
|
+
window.__cloudLayoutHelpPageBase = pageBase;
|
|
53
|
+
const disposers = props.documents.map((document) => registerTopic({ ...document, kind: "document" }));
|
|
54
|
+
onCleanup(() => {
|
|
55
|
+
disposers.forEach((dispose) => dispose());
|
|
56
|
+
if (window.__cloudLayoutHelpPageBase === pageBase) delete window.__cloudLayoutHelpPageBase;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -3,3 +3,42 @@ export const layoutHelpTopicHref = (pageBase: string, topicId: string | null) =>
|
|
|
3
3
|
const base = pageBase.endsWith("/") ? pageBase.slice(0, -1) : pageBase;
|
|
4
4
|
return topicId ? `${base}/${encodeURIComponent(topicId)}` : base;
|
|
5
5
|
};
|
|
6
|
+
|
|
7
|
+
/** The app named by Core's exact full-page Help route. */
|
|
8
|
+
export const helpPageAppId = (pathname: string): string | null => {
|
|
9
|
+
const match = /^\/help\/apps\/([^/]+)(?:\/[^/]+)?\/?$/.exec(pathname);
|
|
10
|
+
if (!match?.[1]) return null;
|
|
11
|
+
try {
|
|
12
|
+
return decodeURIComponent(match[1]);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type HelpLinkTarget = { kind: "section"; hash: string } | { kind: "topic"; topicId: string | null; hash: string };
|
|
19
|
+
|
|
20
|
+
/** Same-app Help links that the reader can open in place. */
|
|
21
|
+
export const helpLinkTarget = (href: string, current: string, help: { appId?: string; pageBase?: string }): HelpLinkTarget | null => {
|
|
22
|
+
try {
|
|
23
|
+
if (href.startsWith("#")) {
|
|
24
|
+
const hash = decodeURIComponent(href.slice(1));
|
|
25
|
+
return hash ? { kind: "section", hash } : null;
|
|
26
|
+
}
|
|
27
|
+
const source = new URL(current);
|
|
28
|
+
const target = new URL(href, source);
|
|
29
|
+
if (target.origin !== source.origin || target.search) return null;
|
|
30
|
+
const bases = [help.pageBase?.replace(/\/$/, ""), help.appId ? `/help/apps/${encodeURIComponent(help.appId)}` : undefined];
|
|
31
|
+
const hash = decodeURIComponent(target.hash.slice(1));
|
|
32
|
+
for (const base of bases) {
|
|
33
|
+
if (!base) continue;
|
|
34
|
+
const path = target.pathname.replace(/\/$/, "");
|
|
35
|
+
if (path === base) return { kind: "topic", topicId: null, hash };
|
|
36
|
+
if (!path.startsWith(`${base}/`)) continue;
|
|
37
|
+
const segment = path.slice(base.length + 1);
|
|
38
|
+
if (segment && !segment.includes("/")) return { kind: "topic", topicId: decodeURIComponent(segment), hash };
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createNavigation } from "@k2b/ui";
|
|
2
|
+
import { render } from "solid-js/web";
|
|
3
|
+
import { openCloudMobileMenu } from "./MobileNavigation";
|
|
4
|
+
import { provideWorkspaceNavigation } from "./workspace-navigation";
|
|
5
|
+
|
|
6
|
+
const owner = document.getElementById("workspace")!;
|
|
7
|
+
render(() => {
|
|
8
|
+
const navigation = createNavigation({
|
|
9
|
+
items: () => [{ id: "notes", label: "Notes", href: "#notes", active: true }],
|
|
10
|
+
switcher: () => ({
|
|
11
|
+
currentId: "work",
|
|
12
|
+
label: "Work",
|
|
13
|
+
icon: "ti-notebook",
|
|
14
|
+
switchLabel: "Switch notebook",
|
|
15
|
+
options: [{ id: "work", label: "Work", href: "#work" }],
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
provideWorkspaceNavigation(navigation, { label: () => "Workspace", owner: () => owner });
|
|
19
|
+
return <button onClick={() => void openCloudMobileMenu({ apps: [], legalLinks: [] })}>Open menu</button>;
|
|
20
|
+
}, owner);
|
|
@@ -56,10 +56,6 @@ export const platformMessages = i18n.define({
|
|
|
56
56
|
latestPlatformUpdates: "Latest platform updates",
|
|
57
57
|
gotIt: "Got it",
|
|
58
58
|
dismissBanner: "Dismiss banner",
|
|
59
|
-
searchTags: "Search tags",
|
|
60
|
-
searchTagsDescription: ({ tag }: { tag: string }) => `Use ${tag} to narrow your search. You can combine text and multiple tags.`,
|
|
61
|
-
tags: "Tags",
|
|
62
|
-
noAppSearchTags: "No app-specific search tags are available.",
|
|
63
59
|
openGlobalSearch: "Open global search",
|
|
64
60
|
globalSearchDescription: "Search across apps, pages, files, and items.",
|
|
65
61
|
searchShortcut: "Search (⌘/Ctrl+K)",
|
|
@@ -127,10 +123,6 @@ export const platformMessages = i18n.define({
|
|
|
127
123
|
latestPlatformUpdates: "Neuigkeiten zur Plattform",
|
|
128
124
|
gotIt: "Verstanden",
|
|
129
125
|
dismissBanner: "Banner ausblenden",
|
|
130
|
-
searchTags: "Such-Tags",
|
|
131
|
-
searchTagsDescription: ({ tag }) => `Verwende ${tag} für eine gezieltere Suche. Text und mehrere Tags lassen sich kombinieren.`,
|
|
132
|
-
tags: "Tags",
|
|
133
|
-
noAppSearchTags: "Keine app-spezifischen Such-Tags verfügbar.",
|
|
134
126
|
openGlobalSearch: "Globale Suche öffnen",
|
|
135
127
|
globalSearchDescription: "Apps, Seiten, Dateien und weitere Inhalte durchsuchen.",
|
|
136
128
|
searchShortcut: "Suchen (⌘/Strg+K)",
|
|
@@ -1,44 +1,87 @@
|
|
|
1
|
-
import type { DropdownItem } from "@k2b/ui";
|
|
1
|
+
import type { DropdownAction, DropdownItem } from "@k2b/ui";
|
|
2
2
|
import { createMemo, createSignal, onCleanup, onMount } from "solid-js";
|
|
3
3
|
import { profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
|
|
4
|
-
import { type
|
|
4
|
+
import { type CloudThemePreference, getCurrentThemePreference, setThemePreference } from "../shared/theme";
|
|
5
5
|
import { profilePreferencesMessages } from "./profile-preferences-messages";
|
|
6
6
|
|
|
7
7
|
const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
|
|
8
|
+
export const THEME_CYCLE = ["system", "light", "dark"] as const satisfies readonly CloudThemePreference[];
|
|
9
|
+
const THEME_ICONS: Record<CloudThemePreference, string> = { light: "ti ti-sun-high", dark: "ti ti-moon", system: "ti ti-device-laptop" };
|
|
8
10
|
|
|
9
|
-
export const
|
|
10
|
-
|
|
11
|
+
export const nextThemePreference = (current: CloudThemePreference): CloudThemePreference =>
|
|
12
|
+
THEME_CYCLE[(THEME_CYCLE.indexOf(current) + 1) % THEME_CYCLE.length]!;
|
|
13
|
+
|
|
14
|
+
export const createPreferenceController = (initialTheme: CloudThemePreference, locale: () => string) => {
|
|
15
|
+
const [theme, updateTheme] = createSignal(initialTheme);
|
|
16
|
+
const [announcedTheme, setAnnouncedTheme] = createSignal<CloudThemePreference>();
|
|
11
17
|
const messages = () => profilePreferencesMessages.resolve([locale()]).t;
|
|
12
18
|
const language = () => profilePreferenceLocale(locale());
|
|
13
19
|
const nextLanguage = () => (language() === "de" ? "en" : "de");
|
|
14
|
-
const themeLabel = () => (theme() === "light" ? messages().switchToDark : messages().switchToLight);
|
|
15
20
|
const languageLabel = () => (nextLanguage() === "de" ? messages().switchToGerman : messages().switchToEnglish);
|
|
16
21
|
|
|
17
|
-
const
|
|
18
|
-
|
|
22
|
+
const setTheme = (mode: CloudThemePreference) => {
|
|
23
|
+
updateTheme(setThemePreference(mode));
|
|
19
24
|
window.dispatchEvent(new Event(THEME_PREFERENCE_EVENT));
|
|
20
25
|
};
|
|
21
26
|
const toggleLanguage = () => setLocalePreference(nextLanguage());
|
|
22
27
|
|
|
23
28
|
onMount(() => {
|
|
24
|
-
const syncTheme = () =>
|
|
29
|
+
const syncTheme = () => {
|
|
30
|
+
const syncedTheme = updateTheme(getCurrentThemePreference());
|
|
31
|
+
if (syncedTheme !== announcedTheme()) setAnnouncedTheme(undefined);
|
|
32
|
+
};
|
|
25
33
|
syncTheme();
|
|
26
34
|
window.addEventListener(THEME_PREFERENCE_EVENT, syncTheme);
|
|
27
35
|
onCleanup(() => window.removeEventListener(THEME_PREFERENCE_EVENT, syncTheme));
|
|
28
36
|
});
|
|
29
37
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
const themeOption = (value: CloudThemePreference) => ({
|
|
39
|
+
value,
|
|
40
|
+
icon: THEME_ICONS[value],
|
|
41
|
+
label: { light: messages().themeLight, dark: messages().themeDark, system: messages().themeSystem }[value],
|
|
42
|
+
});
|
|
43
|
+
const cycleTheme = () => {
|
|
44
|
+
const next = nextThemePreference(theme());
|
|
45
|
+
setTheme(next);
|
|
46
|
+
setAnnouncedTheme(next);
|
|
47
|
+
};
|
|
48
|
+
const themeCycleLabel = () =>
|
|
49
|
+
messages().themeCycle({
|
|
50
|
+
current: themeOption(theme()).label,
|
|
51
|
+
next: themeOption(nextThemePreference(theme())).label,
|
|
52
|
+
});
|
|
53
|
+
const themeAnnouncement = () => {
|
|
54
|
+
const current = announcedTheme();
|
|
55
|
+
return current === undefined ? "" : messages().themeChanged({ current: themeOption(current).label });
|
|
56
|
+
};
|
|
57
|
+
const appearanceItem: DropdownAction = {
|
|
58
|
+
get icon() {
|
|
59
|
+
return themeOption(theme()).icon;
|
|
35
60
|
},
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
icon: "ti ti-language",
|
|
39
|
-
label: languageLabel(),
|
|
61
|
+
get label() {
|
|
62
|
+
return themeOption(theme()).label;
|
|
40
63
|
},
|
|
41
|
-
|
|
64
|
+
get ariaLabel() {
|
|
65
|
+
return themeCycleLabel();
|
|
66
|
+
},
|
|
67
|
+
closeOnSelect: false,
|
|
68
|
+
action: cycleTheme,
|
|
69
|
+
};
|
|
70
|
+
const languageItem = (): DropdownAction => ({ action: toggleLanguage, icon: "ti ti-language", label: languageLabel() });
|
|
71
|
+
const items = createMemo<DropdownItem[]>(() => [appearanceItem, languageItem()]);
|
|
42
72
|
|
|
43
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
items,
|
|
75
|
+
appearanceItem,
|
|
76
|
+
languageItem,
|
|
77
|
+
themeOption,
|
|
78
|
+
language,
|
|
79
|
+
languageLabel,
|
|
80
|
+
messages,
|
|
81
|
+
theme,
|
|
82
|
+
cycleTheme,
|
|
83
|
+
themeCycleLabel,
|
|
84
|
+
themeAnnouncement,
|
|
85
|
+
toggleLanguage,
|
|
86
|
+
};
|
|
44
87
|
};
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { type DropdownItem, toast, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createMemo, createSignal } from "solid-js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import type { CloudTheme } from "../shared/theme";
|
|
3
|
+
import { importOnDemand } from "../browser/reload";
|
|
4
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
6
5
|
import { createPreferenceController } from "./preference-controller";
|
|
7
6
|
|
|
8
7
|
/** `appHref` is set while the installation offers the mobile app; the item then leads to `/me/app`. */
|
|
9
|
-
export function createProfileActions(initialTheme:
|
|
8
|
+
export function createProfileActions(initialTheme: CloudThemePreference, appHref?: string) {
|
|
10
9
|
const locale = useLocale();
|
|
11
10
|
const preferences = createPreferenceController(initialTheme, locale);
|
|
12
11
|
const [signingOut, setSigningOut] = createSignal(false);
|
|
13
12
|
const signOut = async () => {
|
|
14
13
|
if (signingOut()) return;
|
|
15
14
|
setSigningOut(true);
|
|
15
|
+
// The API client and the push client load with the first sign-out, not with every page.
|
|
16
|
+
const loaded = await importOnDemand(() => Promise.all([import("../browser/notifications"), import("../clients/core")]));
|
|
17
|
+
if (!loaded) {
|
|
18
|
+
setSigningOut(false);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const [{ browserNotificationClient }, { apiClient }] = loaded;
|
|
16
22
|
try {
|
|
17
23
|
await browserNotificationClient.disable().catch(() => undefined);
|
|
18
24
|
const response = await apiClient.auth.logout.$post();
|
|
@@ -25,7 +31,8 @@ export function createProfileActions(initialTheme: CloudTheme, appHref?: string)
|
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
const items = createMemo<DropdownItem[]>(() => [
|
|
28
|
-
|
|
34
|
+
preferences.appearanceItem,
|
|
35
|
+
preferences.languageItem(),
|
|
29
36
|
{
|
|
30
37
|
href: "/me",
|
|
31
38
|
icon: "ti ti-user-circle",
|
|
@@ -12,8 +12,11 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
12
12
|
app: "App",
|
|
13
13
|
signOut: "Sign out",
|
|
14
14
|
signOutFailed: "Sign out failed. Please try again.",
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
themeLight: "Light",
|
|
16
|
+
themeDark: "Dark",
|
|
17
|
+
themeSystem: "Automatic",
|
|
18
|
+
themeCycle: ({ current, next }: { current: string; next: string }) => `Appearance: ${current}. Switches to ${next}.`,
|
|
19
|
+
themeChanged: ({ current }: { current: string }) => `Appearance: ${current}`,
|
|
17
20
|
switchToGerman: "Deutsch",
|
|
18
21
|
switchToEnglish: "English",
|
|
19
22
|
},
|
|
@@ -26,8 +29,11 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
26
29
|
app: "App",
|
|
27
30
|
signOut: "Abmelden",
|
|
28
31
|
signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
themeLight: "Hell",
|
|
33
|
+
themeDark: "Dunkel",
|
|
34
|
+
themeSystem: "Automatisch",
|
|
35
|
+
themeCycle: ({ current, next }) => `Darstellung: ${current}. Wechselt zu ${next}.`,
|
|
36
|
+
themeChanged: ({ current }) => `Darstellung: ${current}`,
|
|
31
37
|
switchToGerman: "Deutsch",
|
|
32
38
|
switchToEnglish: "English",
|
|
33
39
|
},
|