@k2b/cloud 0.32.0 → 0.33.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/define-app.ts +17 -8
- package/src/_internal/page-responses.ts +5 -1
- 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/browser/navigation-search.ts +4 -1
- package/src/services/outbox.ts +13 -6
- package/src/shared/ai-platform-prompt.ts +3 -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 +56 -85
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.tsx +2 -57
- package/src/ssr/LayoutHelpTrigger.tsx +51 -0
- package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +10 -7
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/ShellRuntime.island.tsx +170 -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/{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/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/preference-controller.ts +30 -21
- package/src/ssr/profile-actions.ts +26 -15
- package/src/ssr/profile-preferences-messages.ts +8 -4
- package/src/ssr/rail-context.ts +8 -12
- 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/global.css +17 -5
- package/src/styles/utilities-navigation.css +13 -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/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- 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
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -1,44 +1,53 @@
|
|
|
1
|
-
import type { DropdownItem } from "@k2b/ui";
|
|
1
|
+
import type { DropdownAction, DropdownItem, DropdownSection } 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
|
+
const THEME_PREFERENCES = ["light", "dark", "system"] as const satisfies readonly CloudThemePreference[];
|
|
9
|
+
const THEME_ICONS: Record<CloudThemePreference, string> = { light: "ti ti-sun-high", dark: "ti ti-moon", system: "ti ti-sun-moon" };
|
|
8
10
|
|
|
9
|
-
export const createPreferenceController = (initialTheme:
|
|
10
|
-
const [theme,
|
|
11
|
+
export const createPreferenceController = (initialTheme: CloudThemePreference, locale: () => string) => {
|
|
12
|
+
const [theme, updateTheme] = createSignal(initialTheme);
|
|
11
13
|
const messages = () => profilePreferencesMessages.resolve([locale()]).t;
|
|
12
14
|
const language = () => profilePreferenceLocale(locale());
|
|
13
15
|
const nextLanguage = () => (language() === "de" ? "en" : "de");
|
|
14
|
-
const themeLabel = () => (theme() === "light" ? messages().switchToDark : messages().switchToLight);
|
|
15
16
|
const languageLabel = () => (nextLanguage() === "de" ? messages().switchToGerman : messages().switchToEnglish);
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
const setTheme = (mode: CloudThemePreference) => {
|
|
19
|
+
updateTheme(setThemePreference(mode));
|
|
19
20
|
window.dispatchEvent(new Event(THEME_PREFERENCE_EVENT));
|
|
20
21
|
};
|
|
21
22
|
const toggleLanguage = () => setLocalePreference(nextLanguage());
|
|
22
23
|
|
|
23
24
|
onMount(() => {
|
|
24
|
-
const syncTheme = () =>
|
|
25
|
+
const syncTheme = () => updateTheme(getCurrentThemePreference());
|
|
25
26
|
syncTheme();
|
|
26
27
|
window.addEventListener(THEME_PREFERENCE_EVENT, syncTheme);
|
|
27
28
|
onCleanup(() => window.removeEventListener(THEME_PREFERENCE_EVENT, syncTheme));
|
|
28
29
|
});
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
const themeOption = (value: CloudThemePreference) => ({
|
|
32
|
+
value,
|
|
33
|
+
icon: THEME_ICONS[value],
|
|
34
|
+
label: { light: messages().themeLight, dark: messages().themeDark, system: messages().themeSystem }[value],
|
|
35
|
+
});
|
|
36
|
+
const themeOptions = () => THEME_PREFERENCES.map(themeOption);
|
|
37
|
+
const appearance = (): DropdownSection => ({
|
|
38
|
+
sectionLabel: messages().appearance,
|
|
39
|
+
layout: "row",
|
|
40
|
+
items: themeOptions().map(({ value, label, icon }) => ({
|
|
41
|
+
label,
|
|
42
|
+
icon,
|
|
43
|
+
choice: "radio",
|
|
44
|
+
checked: () => theme() === value,
|
|
45
|
+
closeOnSelect: false,
|
|
46
|
+
action: () => setTheme(value),
|
|
47
|
+
})),
|
|
48
|
+
});
|
|
49
|
+
const languageItem = (): DropdownAction => ({ action: toggleLanguage, icon: "ti ti-language", label: languageLabel() });
|
|
50
|
+
const items = createMemo<DropdownItem[]>(() => [appearance(), { items: [languageItem()] }]);
|
|
42
51
|
|
|
43
|
-
return { items, language, languageLabel, messages, theme,
|
|
52
|
+
return { items, appearance, languageItem, themeOption, themeOptions, language, languageLabel, messages, theme, setTheme, toggleLanguage };
|
|
44
53
|
};
|
|
@@ -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,18 +31,23 @@ export function createProfileActions(initialTheme: CloudTheme, appHref?: string)
|
|
|
25
31
|
};
|
|
26
32
|
|
|
27
33
|
const items = createMemo<DropdownItem[]>(() => [
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
href: "/me",
|
|
31
|
-
icon: "ti ti-user-circle",
|
|
32
|
-
label: preferences.messages().profileSettings,
|
|
33
|
-
},
|
|
34
|
-
...(appHref ? [{ href: appHref, icon: "ti ti-device-mobile", label: preferences.messages().app }] : []),
|
|
34
|
+
preferences.appearance(),
|
|
35
35
|
{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
items: [
|
|
37
|
+
preferences.languageItem(),
|
|
38
|
+
{
|
|
39
|
+
href: "/me",
|
|
40
|
+
icon: "ti ti-user-circle",
|
|
41
|
+
label: preferences.messages().profileSettings,
|
|
42
|
+
},
|
|
43
|
+
...(appHref ? [{ href: appHref, icon: "ti ti-device-mobile", label: preferences.messages().app }] : []),
|
|
44
|
+
{
|
|
45
|
+
action: signOut,
|
|
46
|
+
icon: "ti ti-logout",
|
|
47
|
+
label: preferences.messages().signOut,
|
|
48
|
+
disabled: signingOut(),
|
|
49
|
+
},
|
|
50
|
+
],
|
|
40
51
|
},
|
|
41
52
|
]);
|
|
42
53
|
|
|
@@ -12,8 +12,10 @@ 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
|
+
appearance: "Appearance",
|
|
16
|
+
themeLight: "Light",
|
|
17
|
+
themeDark: "Dark",
|
|
18
|
+
themeSystem: "Automatic",
|
|
17
19
|
switchToGerman: "Deutsch",
|
|
18
20
|
switchToEnglish: "English",
|
|
19
21
|
},
|
|
@@ -26,8 +28,10 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
26
28
|
app: "App",
|
|
27
29
|
signOut: "Abmelden",
|
|
28
30
|
signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
appearance: "Darstellung",
|
|
32
|
+
themeLight: "Hell",
|
|
33
|
+
themeDark: "Dunkel",
|
|
34
|
+
themeSystem: "Automatisch",
|
|
31
35
|
switchToGerman: "Deutsch",
|
|
32
36
|
switchToEnglish: "English",
|
|
33
37
|
},
|
package/src/ssr/rail-context.ts
CHANGED
|
@@ -1,26 +1,22 @@
|
|
|
1
1
|
import type { RailPreferences, RailSnapshot } from "../contracts/rail-preferences";
|
|
2
2
|
import type { RailApp } from "./rail-navigation";
|
|
3
|
+
import { readShellData } from "./shell-data";
|
|
3
4
|
|
|
4
5
|
export type RailContext = { apps: RailApp[]; settings: RailSnapshot };
|
|
5
6
|
export const RAIL_PREFERENCES_EVENT = "cloud:rail-preferences";
|
|
6
7
|
|
|
7
8
|
export const readRailContext = (): RailContext | undefined => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!text) return undefined;
|
|
11
|
-
// Framework-generated, escaped SSR data; never loaded from localStorage. The server validates the snapshot when
|
|
12
|
-
// it reads it, so the shell does not load the schemas on every page.
|
|
13
|
-
const value: RailContext = JSON.parse(text);
|
|
14
|
-
return { apps: value.apps, settings: value.settings };
|
|
9
|
+
const data = readShellData();
|
|
10
|
+
return data?.rail ? { apps: data.apps, settings: data.rail } : undefined;
|
|
15
11
|
};
|
|
16
12
|
|
|
17
13
|
export const publishRailPreferences = (settings: RailPreferences) => {
|
|
18
|
-
const
|
|
19
|
-
const element = document.getElementById("cloud-
|
|
20
|
-
if (
|
|
14
|
+
const data = readShellData();
|
|
15
|
+
const element = document.getElementById("cloud-shell-data");
|
|
16
|
+
if (data?.rail && element)
|
|
21
17
|
element.textContent = JSON.stringify({
|
|
22
|
-
...
|
|
23
|
-
|
|
18
|
+
...data,
|
|
19
|
+
rail: { ...settings, managedShortcuts: data.rail.managedShortcuts },
|
|
24
20
|
}).replace(/</g, "\\u003c");
|
|
25
21
|
window.dispatchEvent(new CustomEvent(RAIL_PREFERENCES_EVENT));
|
|
26
22
|
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createContext, type JSX, useContext } from "solid-js";
|
|
2
|
+
import type { AppSearchLink } from "../contracts/app";
|
|
3
|
+
import type { RailSnapshot } from "../contracts/rail-preferences";
|
|
4
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
5
|
+
import type { AppLaunchpadLegalLink } from "./app-launchpad";
|
|
6
|
+
import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
7
|
+
import type { RailApp } from "./rail-navigation";
|
|
8
|
+
|
|
9
|
+
export type ShellData = {
|
|
10
|
+
/** The authorized navigation catalog, sorted for the request locale: the rail, the app grid and the phone menu show it. */
|
|
11
|
+
apps: RailApp[];
|
|
12
|
+
/** Signed-in pages: the person's rail preferences. */
|
|
13
|
+
rail?: RailSnapshot;
|
|
14
|
+
legalLinks: AppLaunchpadLegalLink[];
|
|
15
|
+
/** Signed-in pages: the person, for the profile menu and the phone menu's profile actions. */
|
|
16
|
+
profile?: { name: string; avatarSrc?: string; appHref?: string };
|
|
17
|
+
theme: CloudThemePreference;
|
|
18
|
+
/** The current app's accent, for the Help dialog and the phone menu, which open outside the app canvas. */
|
|
19
|
+
accent?: string;
|
|
20
|
+
/** The search destinations of each app in `apps`, by app id in registry order; the search host expands them into rows. */
|
|
21
|
+
searchLinks: Record<string, readonly AppSearchLink[]>;
|
|
22
|
+
searchHelpApps: GlobalSearchHelpApp[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ShellDataContext = createContext<() => ShellData>();
|
|
26
|
+
|
|
27
|
+
export function ShellDataProvider(props: { data: ShellData; children: JSX.Element }) {
|
|
28
|
+
return (
|
|
29
|
+
<ShellDataContext.Provider value={() => props.data}>
|
|
30
|
+
<script id="cloud-shell-data" type="application/json" innerHTML={JSON.stringify(props.data).replace(/</g, "\\u003c")} />
|
|
31
|
+
{props.children}
|
|
32
|
+
</ShellDataContext.Provider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readShellData(): ShellData | undefined {
|
|
37
|
+
if (typeof document === "undefined") return undefined;
|
|
38
|
+
const text = document.getElementById("cloud-shell-data")?.textContent;
|
|
39
|
+
if (!text) return undefined;
|
|
40
|
+
// Framework-generated, escaped SSR data; never loaded from localStorage. The server validates the rail snapshot when
|
|
41
|
+
// it reads it, so the shell does not load the schemas on every page.
|
|
42
|
+
const data: ShellData = JSON.parse(text);
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useShellData(): ShellData | undefined {
|
|
47
|
+
return useContext(ShellDataContext)?.() ?? readShellData();
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cookies } from "@k2b/stdlib/browser";
|
|
2
|
+
import { TIMEZONE_COOKIE } from "../shared/time";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stores the browser's timezone for server-rendered times. With `reload`, a
|
|
6
|
+
* changed timezone reloads the page once, so it shows times in that zone.
|
|
7
|
+
*/
|
|
8
|
+
export const syncTimezoneCookie = (reload: boolean) => {
|
|
9
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
10
|
+
const current = cookies.readCookie(TIMEZONE_COOKIE);
|
|
11
|
+
if (!timeZone || current === timeZone) return;
|
|
12
|
+
cookies.writeCookie(TIMEZONE_COOKIE, timeZone);
|
|
13
|
+
if (!reload) return;
|
|
14
|
+
try {
|
|
15
|
+
if (sessionStorage.getItem("cloud.timezone.reload") !== timeZone) {
|
|
16
|
+
sessionStorage.setItem("cloud.timezone.reload", timeZone);
|
|
17
|
+
window.location.reload();
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// Cookie persistence still succeeds; the next navigation will render with the browser timezone.
|
|
21
|
+
}
|
|
22
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createNavigation, type NavigationController, type NavigationItem } from "@k2b/ui";
|
|
1
|
+
import { type AppWorkspaceSidebarSwitcherProps, createNavigation, type NavigationController, type NavigationItem } from "@k2b/ui";
|
|
2
2
|
import { createEffect, onCleanup, onMount } from "solid-js";
|
|
3
3
|
|
|
4
4
|
export type WorkspaceNavigationState = { navigation: NavigationController; label: string; owner: Element };
|
|
@@ -44,6 +44,28 @@ function validItems(value: unknown): value is NavigationItem[] {
|
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function readSwitcher(value: unknown): AppWorkspaceSidebarSwitcherProps | undefined {
|
|
48
|
+
if (
|
|
49
|
+
!value ||
|
|
50
|
+
typeof value !== "object" ||
|
|
51
|
+
!("currentId" in value) ||
|
|
52
|
+
typeof value.currentId !== "string" ||
|
|
53
|
+
!("label" in value) ||
|
|
54
|
+
typeof value.label !== "string" ||
|
|
55
|
+
!("icon" in value) ||
|
|
56
|
+
typeof value.icon !== "string" ||
|
|
57
|
+
!("switchLabel" in value) ||
|
|
58
|
+
typeof value.switchLabel !== "string"
|
|
59
|
+
)
|
|
60
|
+
return;
|
|
61
|
+
const description = "description" in value ? value.description : undefined;
|
|
62
|
+
const color = "color" in value ? value.color : undefined;
|
|
63
|
+
if (description !== undefined && typeof description !== "string") return;
|
|
64
|
+
if (color !== undefined && typeof color !== "string") return;
|
|
65
|
+
// The snapshot carries only the head; options and actions wait for the owning island.
|
|
66
|
+
return { currentId: value.currentId, label: value.label, icon: value.icon, switchLabel: value.switchLabel, description, color };
|
|
67
|
+
}
|
|
68
|
+
|
|
47
69
|
export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined {
|
|
48
70
|
if (typeof window === "undefined") return;
|
|
49
71
|
const current = window.__cloudWorkspaceNavigation;
|
|
@@ -67,10 +89,11 @@ export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined
|
|
|
67
89
|
...(item.inlineActions ? { inlineActions: item.inlineActions.map((action) => ({ ...action, disabled: true })) } : {}),
|
|
68
90
|
}));
|
|
69
91
|
const items = links(value.items);
|
|
92
|
+
const switcher = "switcher" in value ? readSwitcher(value.switcher) : undefined;
|
|
70
93
|
return {
|
|
71
94
|
owner,
|
|
72
95
|
label: "label" in value && typeof value.label === "string" ? value.label : "",
|
|
73
|
-
navigation: createNavigation({ items: () => items }),
|
|
96
|
+
navigation: createNavigation({ items: () => items, ...(switcher ? { switcher: () => switcher } : {}) }),
|
|
74
97
|
};
|
|
75
98
|
} catch {
|
|
76
99
|
return;
|