@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,9 +1,9 @@
|
|
|
1
1
|
import { Dropdown, type DropdownPosition, useLocale } from "@k2b/ui";
|
|
2
|
-
import type {
|
|
2
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
3
3
|
import { createPreferenceController } from "./preference-controller";
|
|
4
4
|
|
|
5
5
|
type LayoutPreferencesProps = {
|
|
6
|
-
initialTheme:
|
|
6
|
+
initialTheme: CloudThemePreference;
|
|
7
7
|
position: DropdownPosition;
|
|
8
8
|
};
|
|
9
9
|
|
package/src/ssr/LayoutRail.tsx
CHANGED
|
@@ -1,75 +1,21 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import AppLaunchpad, { type AppLaunchpadApp } from "./AppLaunchpad.island";
|
|
5
|
-
import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
6
|
-
import GlobalSearchTrigger from "./GlobalSearchTrigger.island";
|
|
7
|
-
import LayoutHelpTrigger from "./LayoutHelpTrigger.island";
|
|
8
|
-
import ProfilePreferences from "./ProfilePreferences.island";
|
|
9
|
-
import RailApps from "./RailApps.island";
|
|
1
|
+
import { useLocale } from "@k2b/ui";
|
|
2
|
+
import CloudShell from "./CloudShell.island";
|
|
3
|
+
import { platformMessages } from "./platform-messages";
|
|
10
4
|
import type { RailApp } from "./rail-navigation";
|
|
11
5
|
export type LayoutAppLink = RailApp;
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
appsLabel: string;
|
|
18
|
-
homeLabel: string;
|
|
19
|
-
launchpadApps: AppLaunchpadApp[];
|
|
20
|
-
legalLinks: Array<{ label: string; href: string; icon?: string }>;
|
|
21
|
-
openAppsLabel: string;
|
|
22
|
-
apps: RailApp[];
|
|
23
|
-
railSettings: RailSnapshot;
|
|
24
|
-
/** `pathname + search` of the request; the island resolves it against the browser origin. */
|
|
25
|
-
currentPath: string;
|
|
26
|
-
profileAvatarSrc?: string;
|
|
27
|
-
profileName: string;
|
|
28
|
-
searchHelpApps: GlobalSearchHelpApp[];
|
|
29
|
-
searchLinks?: NavigationSearchItem[];
|
|
30
|
-
theme: CloudTheme;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const jsonScript = (value: unknown): string => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
34
|
-
|
|
35
|
-
export default function LayoutRail(props: LayoutRailProps) {
|
|
36
|
-
const profile = { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) };
|
|
7
|
+
/** The rail frame and Home stay server markup; only the navigation is interactive. */
|
|
8
|
+
export default function LayoutRail(props: { currentPath: string }) {
|
|
9
|
+
const locale = useLocale();
|
|
10
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
37
11
|
return (
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
|
|
47
|
-
<div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
|
|
48
|
-
<a href="/" aria-label={props.homeLabel}>
|
|
49
|
-
<img src="/branding/logo" alt="Logo" class="h-5 w-5" />
|
|
50
|
-
</a>
|
|
51
|
-
</div>
|
|
52
|
-
<nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
|
|
53
|
-
<RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
|
|
54
|
-
<AppLaunchpad
|
|
55
|
-
profile={profile}
|
|
56
|
-
apps={props.launchpadApps}
|
|
57
|
-
legalLinks={props.legalLinks}
|
|
58
|
-
variant="rail"
|
|
59
|
-
label={props.openAppsLabel}
|
|
60
|
-
/>
|
|
61
|
-
<div class="mt-auto shrink-0 flex flex-col items-center gap-1">
|
|
62
|
-
<GlobalSearchTrigger variant="rail" searchLinks={props.searchLinks} />
|
|
63
|
-
<LayoutHelpTrigger variant="rail" registerCommand searchHelpApps={props.searchHelpApps} accent={props.accent} />
|
|
64
|
-
<ProfilePreferences
|
|
65
|
-
appHref={props.appHref}
|
|
66
|
-
avatarSrc={props.profileAvatarSrc}
|
|
67
|
-
initialTheme={props.theme}
|
|
68
|
-
name={props.profileName}
|
|
69
|
-
/>
|
|
70
|
-
</div>
|
|
71
|
-
</nav>
|
|
72
|
-
</aside>
|
|
73
|
-
</>
|
|
12
|
+
<aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
|
|
13
|
+
<div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
|
|
14
|
+
<a href="/" aria-label={t().home}>
|
|
15
|
+
<img src="/branding/logo" alt="Logo" class="h-5 w-5" />
|
|
16
|
+
</a>
|
|
17
|
+
</div>
|
|
18
|
+
<CloudShell slot="rail" currentPath={props.currentPath} />
|
|
19
|
+
</aside>
|
|
74
20
|
);
|
|
75
21
|
}
|
|
@@ -2,7 +2,7 @@ import { LocaleProvider } from "@k2b/ui";
|
|
|
2
2
|
import type { JSX } from "solid-js/jsx-runtime";
|
|
3
3
|
import { getLocale } from "../server/locale";
|
|
4
4
|
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
5
|
-
import {
|
|
5
|
+
import { readThemePreferenceFromCookieHeader } from "../shared/theme";
|
|
6
6
|
import type { MinimalLayoutContext } from "./layout-context";
|
|
7
7
|
import MinimalLayoutPreferences from "./MinimalLayoutPreferences.island";
|
|
8
8
|
import { profilePreferencesMessages } from "./profile-preferences-messages";
|
|
@@ -26,9 +26,8 @@ export type MinimalLayoutProps = {
|
|
|
26
26
|
|
|
27
27
|
export default function MinimalLayout(props: MinimalLayoutProps) {
|
|
28
28
|
const cookie = props.c.req.raw.headers.get("Cookie") ?? "";
|
|
29
|
-
const theme =
|
|
29
|
+
const theme = readThemePreferenceFromCookieHeader(cookie);
|
|
30
30
|
const locale = getLocale(props.c);
|
|
31
|
-
props.c.get("page").theme = theme;
|
|
32
31
|
|
|
33
32
|
if (props.preferences === false) {
|
|
34
33
|
return (
|
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Dropdown, useLocale } from "@k2b/ui";
|
|
2
2
|
import { canonicalLocale } from "../shared/locale";
|
|
3
3
|
import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
|
|
4
|
-
import type {
|
|
4
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
5
5
|
import { createPreferenceController } from "./preference-controller";
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
* language trigger names the current language; the theme button names the
|
|
10
|
-
* mode it switches to, like every other Cloud preference menu. Below `md` both
|
|
11
|
-
* are compact, the language code and the theme icon with the full names as
|
|
12
|
-
* accessible labels, so they fit next to the legal links on a phone. CSS picks
|
|
13
|
-
* the form, so the server response is already final.
|
|
14
|
-
*/
|
|
15
|
-
export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
|
|
7
|
+
/** Both menus name the current choice; compact triggers keep accessible names below md. */
|
|
8
|
+
export default function MinimalLayoutPreferences(props: { initialTheme: CloudThemePreference }) {
|
|
16
9
|
const locale = useLocale();
|
|
17
10
|
const preferences = createPreferenceController(props.initialTheme, locale);
|
|
18
11
|
const t = preferences.messages;
|
|
12
|
+
const currentTheme = () => preferences.themeOption(preferences.theme());
|
|
19
13
|
const language = () => profilePreferenceLocale(locale());
|
|
20
14
|
const languageName = () => (language() === "de" ? t().switchToGerman : t().switchToEnglish);
|
|
21
15
|
// Keep a regional locale such as en-GB when its language is chosen again,
|
|
@@ -43,10 +37,13 @@ export default function MinimalLayoutPreferences(props: { initialTheme: CloudThe
|
|
|
43
37
|
<i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
|
|
44
38
|
</Dropdown.Trigger>
|
|
45
39
|
</Dropdown.Root>
|
|
46
|
-
<
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
<Dropdown.Root label={t().appearance} position="top-right" width="12rem" items={preferences.appearance().items}>
|
|
41
|
+
<Dropdown.Trigger label={`${t().appearance}: ${currentTheme().label}`} size="sm" tooltip={false} variant="ghost">
|
|
42
|
+
<i class={currentTheme().icon} aria-hidden="true" />
|
|
43
|
+
<span class="hidden md:inline">{currentTheme().label}</span>
|
|
44
|
+
<i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
|
|
45
|
+
</Dropdown.Trigger>
|
|
46
|
+
</Dropdown.Root>
|
|
50
47
|
</div>
|
|
51
48
|
);
|
|
52
49
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { BottomSheet, Navigation, SegmentedControl, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createSignal, onCleanup, Show } from "solid-js";
|
|
3
3
|
import { type AppLaunchpadContext, AppLaunchpadPanel } from "./AppLaunchpadPanel";
|
|
4
|
+
import { appAccentStyle } from "./app-appearance";
|
|
4
5
|
import { openMobileMenu } from "./mobile-menu-history";
|
|
5
6
|
import { platformMessages } from "./platform-messages";
|
|
7
|
+
import { readShellData } from "./shell-data";
|
|
6
8
|
import { observeWorkspaceNavigation, readWorkspaceNavigation } from "./workspace-navigation";
|
|
7
9
|
|
|
8
10
|
export function openCloudMobileMenu(apps: AppLaunchpadContext) {
|
|
@@ -58,14 +60,17 @@ function MobileNavigation(props: {
|
|
|
58
60
|
when={workspace() && tab() === "app"}
|
|
59
61
|
fallback={<AppLaunchpadPanel surface="sheet" {...props.apps} close={props.close} beforeSelect={props.beforeSelect} />}
|
|
60
62
|
>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
{/* The sheet opens outside the app canvas, so the app menu takes the app's accent here, as the canvas does. */}
|
|
64
|
+
<div class="app-accent-scope" style={appAccentStyle(readShellData()?.accent)}>
|
|
65
|
+
<Navigation
|
|
66
|
+
navigation={workspace()!.navigation}
|
|
67
|
+
label={workspace()!.label || t().appMenu}
|
|
68
|
+
beforeSelect={async () => {
|
|
69
|
+
const owner = workspace()?.owner;
|
|
70
|
+
return (await props.beforeSelect()) && Boolean(owner?.isConnected && readWorkspaceNavigation()?.owner === owner);
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
69
74
|
</Show>
|
|
70
75
|
</BottomSheet.Body>
|
|
71
76
|
</BottomSheet>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { documentNavigate } from "@k2b/ssr/nav";
|
|
2
|
-
import { Button, ButtonLink } from "@k2b/ui";
|
|
3
|
-
import type {
|
|
2
|
+
import { Button, ButtonLink, SegmentedControl } from "@k2b/ui";
|
|
3
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
4
4
|
import { createProfileActions } from "./profile-actions";
|
|
5
5
|
|
|
6
6
|
export function MobileProfileActions(props: {
|
|
7
|
-
profile: { name: string; theme:
|
|
7
|
+
profile: { name: string; theme: CloudThemePreference; appHref?: string };
|
|
8
8
|
beforeSelect: () => Promise<boolean>;
|
|
9
9
|
}) {
|
|
10
10
|
const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
|
|
@@ -17,6 +17,13 @@ export function MobileProfileActions(props: {
|
|
|
17
17
|
};
|
|
18
18
|
return (
|
|
19
19
|
<section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
|
|
20
|
+
<SegmentedControl
|
|
21
|
+
class="cloud-mobile-profile__appearance"
|
|
22
|
+
ariaLabel={preferences.messages().appearance}
|
|
23
|
+
options={preferences.themeOptions()}
|
|
24
|
+
value={preferences.theme}
|
|
25
|
+
onValueChange={preferences.setTheme}
|
|
26
|
+
/>
|
|
20
27
|
<ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
|
|
21
28
|
<i class="ti ti-user-circle" aria-hidden="true" />
|
|
22
29
|
{preferences.messages().profileSettings}
|
|
@@ -27,10 +34,6 @@ export function MobileProfileActions(props: {
|
|
|
27
34
|
{preferences.messages().app}
|
|
28
35
|
</ButtonLink>
|
|
29
36
|
)}
|
|
30
|
-
<Button variant="ghost" onClick={preferences.toggleTheme}>
|
|
31
|
-
<i class={`ti ${preferences.theme() === "light" ? "ti-moon" : "ti-sun-high"}`} aria-hidden="true" />
|
|
32
|
-
{preferences.themeLabel()}
|
|
33
|
-
</Button>
|
|
34
37
|
<Button
|
|
35
38
|
variant="ghost"
|
|
36
39
|
onClick={async () => {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PageTitle, type PageTrailState } from "@k2b/ui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shell's own place for the way up, above pages without a workspace. It
|
|
5
|
+
* shows only on a page with a page above it, and steps back when the page
|
|
6
|
+
* renders a workspace (whose head shows it) or its own `PageTrail`.
|
|
7
|
+
*/
|
|
8
|
+
export default function PageTrailRow(props: { trail: PageTrailState }) {
|
|
9
|
+
return (
|
|
10
|
+
<div class="layout-page-head" data-trail={props.trail.ancestors.length ? "up" : "title"}>
|
|
11
|
+
<PageTitle trail={props.trail} as="p" fallback />
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { Avatar, Dropdown } from "@k2b/ui";
|
|
2
|
-
import type {
|
|
1
|
+
import { Avatar, Dropdown, SegmentedControl } from "@k2b/ui";
|
|
2
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
3
3
|
import { createProfileActions } from "./profile-actions";
|
|
4
4
|
|
|
5
5
|
type ProfilePreferencesProps = {
|
|
6
6
|
/** Set while the installation offers the mobile app. */
|
|
7
7
|
appHref?: string;
|
|
8
8
|
avatarSrc?: string;
|
|
9
|
-
initialTheme:
|
|
9
|
+
initialTheme: CloudThemePreference;
|
|
10
10
|
name: string;
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -26,10 +26,15 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
|
26
26
|
{avatar()}
|
|
27
27
|
</button>
|
|
28
28
|
<div class="layout-profile-preferences__panel dropdown-menu-surface">
|
|
29
|
-
<
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
<div class="layout-profile-preferences__appearance">
|
|
30
|
+
<SegmentedControl
|
|
31
|
+
size="sm"
|
|
32
|
+
ariaLabel={preferences.messages().appearance}
|
|
33
|
+
options={preferences.themeOptions().map(({ value, label }) => ({ value, label }))}
|
|
34
|
+
value={preferences.theme}
|
|
35
|
+
onValueChange={preferences.setTheme}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
33
38
|
<button type="button" class="menu-item" onClick={preferences.toggleLanguage}>
|
|
34
39
|
<i class="ti ti-language" aria-hidden="true" />
|
|
35
40
|
<span>{preferences.languageLabel()}</span>
|
package/src/ssr/PwaLayout.tsx
CHANGED
|
@@ -2,7 +2,6 @@ import { LocaleProvider, MobileShell, TabBar, type TabBarItem } from "@k2b/ui";
|
|
|
2
2
|
import type { JSX } from "solid-js";
|
|
3
3
|
import { PWA_SCOPE } from "../contracts/pwa";
|
|
4
4
|
import { getLocale } from "../server/locale";
|
|
5
|
-
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
6
5
|
import { visiblePwaParts } from "./app-navigation";
|
|
7
6
|
import type { LayoutContext } from "./layout-context";
|
|
8
7
|
import PwaRuntime from "./PwaRuntime.island";
|
|
@@ -37,7 +36,6 @@ export default function PwaLayout(props: PwaLayoutProps) {
|
|
|
37
36
|
const user = c.get("user");
|
|
38
37
|
const cloud = c.get("settings")?.app?.name || "Cloud";
|
|
39
38
|
const page = c.get("page");
|
|
40
|
-
page.theme = readThemeFromCookieHeader(c.req.raw.headers.get("Cookie"));
|
|
41
39
|
page.title = props.title;
|
|
42
40
|
page.pwa = { name: cloud };
|
|
43
41
|
// No app page may be framed, and none leaks its address to another site.
|
|
@@ -7,7 +7,7 @@ import { pwaMessages } from "./pwa-messages";
|
|
|
7
7
|
/** Set once per browsing session, so the first page renews and later navigations do not. */
|
|
8
8
|
const RENEWED_MARKER = "cloud.pwa.renewed";
|
|
9
9
|
const KEEPALIVE_INTERVAL_MS = 60 * 60 * 1000;
|
|
10
|
-
/**
|
|
10
|
+
/** Fires for preference changes and system scheme changes from the head script. */
|
|
11
11
|
const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
|
|
12
12
|
|
|
13
13
|
export type PwaRuntimeProps = {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { HelpDocumentManifest } from "@k2b/cloud/shared";
|
|
2
|
+
import {
|
|
3
|
+
followPageTrail,
|
|
4
|
+
installAppWorkspaceController,
|
|
5
|
+
installPageTitleController,
|
|
6
|
+
serializeAppWorkspaceLayoutState,
|
|
7
|
+
useLocale,
|
|
8
|
+
} from "@k2b/ui";
|
|
9
|
+
import { createEffect, onCleanup, onMount } from "solid-js";
|
|
10
|
+
import { appWorkspaceCookieName, readAppWorkspaceLayoutCookie } from "../_internal/app-workspace-state";
|
|
11
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
12
|
+
import { attachCommandShortcuts } from "../browser/command-shortcuts";
|
|
13
|
+
import { navigationSearchItems } from "../browser/navigation-search";
|
|
14
|
+
import { importOnDemand } from "../browser/reload";
|
|
15
|
+
import { registerGlobalSearchHost } from "../browser/search-bridge";
|
|
16
|
+
import {
|
|
17
|
+
ANNOUNCEMENTS_COOKIE,
|
|
18
|
+
ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
|
|
19
|
+
mergeAnnouncementCookieState,
|
|
20
|
+
parseAnnouncementCookieHeader,
|
|
21
|
+
serializeAnnouncementCookieState,
|
|
22
|
+
} from "../contracts/announcement-cookie";
|
|
23
|
+
import type { AnnouncementDisplayEntry } from "../contracts/announcements";
|
|
24
|
+
import { createGlobalSearchHost } from "./global-search-host";
|
|
25
|
+
import { LayoutHelpDocuments } from "./layout-help-registry";
|
|
26
|
+
import { installPageTrailRuntime } from "./page-trail-runtime";
|
|
27
|
+
import { platformMessages } from "./platform-messages";
|
|
28
|
+
import { readShellData } from "./shell-data";
|
|
29
|
+
import { syncTimezoneCookie } from "./timezone-cookie";
|
|
30
|
+
|
|
31
|
+
export type ShellRuntimeProps = {
|
|
32
|
+
/** Layout pages outside focus mode: keep the current app's workspace layout in its cookie. */
|
|
33
|
+
workspace?: { appId?: string };
|
|
34
|
+
/** Layout pages: page titles, the way up (Alt+Up) and its per-tab memory. */
|
|
35
|
+
pageTrail?: boolean;
|
|
36
|
+
/** The current app's Help documents. */
|
|
37
|
+
help?: { documents: readonly HelpDocumentManifest[]; pageBase: string };
|
|
38
|
+
/** Layout pages outside focus mode: the search host, Cmd/Ctrl+K, the Help command (Shift+/) and command shortcuts. */
|
|
39
|
+
commands?: { searchResources: boolean };
|
|
40
|
+
/** Signed-in pages: refresh this browser's push endpoint. */
|
|
41
|
+
push?: boolean;
|
|
42
|
+
/** Announcements the person has not seen yet; a dialog shows them once. */
|
|
43
|
+
announcements?: { entries: AnnouncementDisplayEntry[]; latestVersion: number };
|
|
44
|
+
/** Banners rendered by Layout: their dismiss buttons record the dismissal. */
|
|
45
|
+
banners?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The search rows of the shell's destinations; the page carries each link once, without the fields a row repeats. */
|
|
49
|
+
const searchItems = () => {
|
|
50
|
+
const data = readShellData();
|
|
51
|
+
if (!data) return [];
|
|
52
|
+
const apps = Object.entries(data.searchLinks).flatMap(([id, searchLinks]) => {
|
|
53
|
+
const app = data.apps.find((candidate) => candidate.id === id);
|
|
54
|
+
return app ? [{ id, name: app.label, icon: app.iconClass, searchLinks }] : [];
|
|
55
|
+
});
|
|
56
|
+
return navigationSearchItems(
|
|
57
|
+
apps,
|
|
58
|
+
apps.map((app) => app.id),
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default function ShellRuntime(props: ShellRuntimeProps) {
|
|
63
|
+
const locale = useLocale();
|
|
64
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
65
|
+
onMount(() => syncTimezoneCookie(true));
|
|
66
|
+
|
|
67
|
+
if (props.workspace) {
|
|
68
|
+
onMount(() => {
|
|
69
|
+
const appId = props.workspace?.appId;
|
|
70
|
+
onCleanup(
|
|
71
|
+
installAppWorkspaceController({
|
|
72
|
+
readState: () => readAppWorkspaceLayoutCookie(document.cookie, appId),
|
|
73
|
+
writeState: (state) => {
|
|
74
|
+
if (!appId) return;
|
|
75
|
+
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
|
76
|
+
document.cookie = `${appWorkspaceCookieName(appId)}=${serializeAppWorkspaceLayoutState(state)}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (props.pageTrail) {
|
|
83
|
+
onMount(() => {
|
|
84
|
+
const messages = t();
|
|
85
|
+
const disposeTitles = installPageTitleController(document);
|
|
86
|
+
const disposeFollow = followPageTrail(document);
|
|
87
|
+
const disposeRuntime = installPageTrailRuntime((page) => ({
|
|
88
|
+
title: messages.pageUp({ page }),
|
|
89
|
+
description: messages.pageUpDescription,
|
|
90
|
+
}));
|
|
91
|
+
onCleanup(() => {
|
|
92
|
+
disposeTitles();
|
|
93
|
+
disposeFollow();
|
|
94
|
+
disposeRuntime();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (props.commands) {
|
|
99
|
+
const host = createGlobalSearchHost(searchItems, props.commands.searchResources);
|
|
100
|
+
onMount(() => onCleanup(registerGlobalSearchHost(host.open, host.dispose)));
|
|
101
|
+
// Command schemas and the capability client load with the first shortcut.
|
|
102
|
+
onMount(() =>
|
|
103
|
+
onCleanup(
|
|
104
|
+
attachCommandShortcuts(
|
|
105
|
+
(command) =>
|
|
106
|
+
void importOnDemand(() => import("../browser/commands")).then((commands) => commands?.runContextAwareCommand(command)),
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
createEffect(() =>
|
|
111
|
+
onCleanup(
|
|
112
|
+
registerContextAwareCommand({
|
|
113
|
+
id: "cloud.search",
|
|
114
|
+
title: t().openGlobalSearch,
|
|
115
|
+
description: t().globalSearchDescription,
|
|
116
|
+
icon: "ti ti-search",
|
|
117
|
+
shortcut: "mod+k",
|
|
118
|
+
action: { search: {} },
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
createEffect(() =>
|
|
123
|
+
onCleanup(
|
|
124
|
+
registerContextAwareCommand({
|
|
125
|
+
id: "cloud.help",
|
|
126
|
+
title: t().openShortcutHelp,
|
|
127
|
+
description: t().shortcutHelpDescription,
|
|
128
|
+
icon: "ti ti-help-circle",
|
|
129
|
+
shortcut: "shift+/",
|
|
130
|
+
// Returning the load keeps the Command busy until the dialog is open; a second press opens no second dialog.
|
|
131
|
+
action: () =>
|
|
132
|
+
importOnDemand(() => import("./LayoutHelp")).then((help) => {
|
|
133
|
+
const data = readShellData();
|
|
134
|
+
help?.openLayoutHelpDialog(data?.searchHelpApps ?? [], data?.accent);
|
|
135
|
+
}),
|
|
136
|
+
}),
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
onMount(() => {
|
|
141
|
+
if (props.push) {
|
|
142
|
+
void import("../browser/notifications")
|
|
143
|
+
.then(({ browserNotificationClient }) => browserNotificationClient.refreshExisting())
|
|
144
|
+
.catch((error) => {
|
|
145
|
+
console.warn("[notifications] Failed to refresh the browser endpoint", error);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (props.announcements?.entries.length) {
|
|
149
|
+
const announcements = props.announcements;
|
|
150
|
+
void importOnDemand(() => import("./announcements-dialog")).then((loaded) => loaded?.openAnnouncementsDialog(announcements));
|
|
151
|
+
}
|
|
152
|
+
if (props.banners) {
|
|
153
|
+
const dismiss = (event: MouseEvent) => {
|
|
154
|
+
if (!(event.target instanceof Element)) return;
|
|
155
|
+
const button = event.target.closest<HTMLButtonElement>("[data-cloud-announcement-dismiss]");
|
|
156
|
+
if (!button) return;
|
|
157
|
+
const version = Number(button.dataset.cloudAnnouncementDismiss);
|
|
158
|
+
const next = mergeAnnouncementCookieState(parseAnnouncementCookieHeader(document.cookie), { dismissedBannerVersions: [version] });
|
|
159
|
+
document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(next)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
|
160
|
+
const banner = button.closest(".cloud-announcement");
|
|
161
|
+
const list = banner?.parentElement;
|
|
162
|
+
banner?.remove();
|
|
163
|
+
if (list && !list.querySelector(".cloud-announcement")) list.remove();
|
|
164
|
+
};
|
|
165
|
+
document.addEventListener("click", dismiss);
|
|
166
|
+
onCleanup(() => document.removeEventListener("click", dismiss));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return props.help ? <LayoutHelpDocuments documents={props.help.documents} pageBase={props.help.pageBase} /> : null;
|
|
170
|
+
}
|
|
@@ -1,29 +1,13 @@
|
|
|
1
|
-
import { cookies } from "@k2b/stdlib/browser";
|
|
2
1
|
import { onMount } from "solid-js";
|
|
3
|
-
import {
|
|
2
|
+
import { syncTimezoneCookie } from "./timezone-cookie";
|
|
4
3
|
|
|
5
4
|
export type TimezoneCookieProps = {
|
|
6
5
|
/** Reload once after a timezone change, so the page shows times in the browser's zone. Defaults to `true`. */
|
|
7
6
|
reload?: boolean;
|
|
8
7
|
};
|
|
9
8
|
|
|
9
|
+
/** The timezone cookie for layouts without the shell runtime: `MinimalLayout` and `PwaLayout`. */
|
|
10
10
|
export default function TimezoneCookie(props: TimezoneCookieProps) {
|
|
11
|
-
onMount(() =>
|
|
12
|
-
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
13
|
-
const current = cookies.readCookie(TIMEZONE_COOKIE);
|
|
14
|
-
if (timeZone && current !== timeZone) {
|
|
15
|
-
cookies.writeCookie(TIMEZONE_COOKIE, timeZone);
|
|
16
|
-
if (props.reload === false) return;
|
|
17
|
-
try {
|
|
18
|
-
if (sessionStorage.getItem("cloud.timezone.reload") !== timeZone) {
|
|
19
|
-
sessionStorage.setItem("cloud.timezone.reload", timeZone);
|
|
20
|
-
window.location.reload();
|
|
21
|
-
}
|
|
22
|
-
} catch {
|
|
23
|
-
// Cookie persistence still succeeds; the next navigation will render with the browser timezone.
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
11
|
+
onMount(() => syncTimezoneCookie(props.reload !== false));
|
|
28
12
|
return null;
|
|
29
13
|
}
|
|
@@ -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
|
+
}
|