@k2b/cloud 0.30.0 → 0.31.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 +3 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +14 -4
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/chat/blocks.tsx +53 -32
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/messages.ts +18 -0
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +4 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +94 -25
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/executor.ts +170 -14
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +8 -0
- package/src/ai/migrate.ts +113 -2
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +39 -0
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/widgets.ts +36 -5
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
package/src/ssr/AdminLayout.tsx
CHANGED
|
@@ -33,13 +33,10 @@ export default function AdminLayout(props: Props) {
|
|
|
33
33
|
const url = new URL(c.req.raw.url);
|
|
34
34
|
const currentPath = `${url.pathname}${url.search}`;
|
|
35
35
|
const runtime = getLocalizedRuntimeContext(c);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
];
|
|
40
|
-
if (title !== "Overview" && title !== t.overview) {
|
|
41
|
-
breadcrumbs.push({ title });
|
|
42
|
-
}
|
|
36
|
+
// Admin is the area's root, as an app's root is: the rail and the admin sidebar lead there. So it is no level
|
|
37
|
+
// above an admin page, and every admin page shows its title alone, whichever app owns it.
|
|
38
|
+
const overview = title === "Overview" || title === t.overview;
|
|
39
|
+
const breadcrumbs: Breadcrumb[] = [{ title: t.start, href: "/" }, { title: overview ? t.admin : title }];
|
|
43
40
|
return (
|
|
44
41
|
<Layout c={c} fullWidth fullPage title={breadcrumbs}>
|
|
45
42
|
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1" resizable={false}>
|
|
@@ -60,7 +60,7 @@ export function openAppLaunchpad(apps?: AppLaunchpadApp[], legalLinks?: AppLaunc
|
|
|
60
60
|
const context = apps ? { apps, legalLinks: legalLinks ?? [], profile } : (window.__cloudAppLaunchpad ?? readEmbeddedContext());
|
|
61
61
|
if (!context) return;
|
|
62
62
|
window.__cloudAppLaunchpad = context;
|
|
63
|
-
if (window.matchMedia("(max-width:
|
|
63
|
+
if (window.matchMedia("(max-width: 767px)").matches) {
|
|
64
64
|
void openCloudMobileMenu(context);
|
|
65
65
|
return;
|
|
66
66
|
}
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DateContext } from "@k2b/stdlib";
|
|
2
|
-
import { AppWorkspace, appWorkspaceLayoutStyle, LocaleProvider, NoticeCard } from "@k2b/ui";
|
|
2
|
+
import { AppWorkspace, appWorkspaceLayoutStyle, LocaleProvider, NoticeCard, PageTrailProvider } from "@k2b/ui";
|
|
3
3
|
import type { JSX } from "solid-js/jsx-runtime";
|
|
4
4
|
import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
|
|
5
5
|
import { navigationSearchItems } from "../browser/navigation-search";
|
|
@@ -13,8 +13,8 @@ import { dates } from "../shared";
|
|
|
13
13
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
14
14
|
import type { AppLaunchpadApp } from "./AppLaunchpad.island";
|
|
15
15
|
import AppWorkspaceController from "./AppWorkspaceController.island";
|
|
16
|
-
import { appAppearanceStyle
|
|
17
|
-
import { visibleNavigationApps } from "./app-navigation";
|
|
16
|
+
import { appAppearanceStyle } from "./app-appearance";
|
|
17
|
+
import { resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
|
|
18
18
|
import BrowserPushRegistration from "./BrowserPushRegistration.island";
|
|
19
19
|
import GlobalAnnouncements from "./GlobalAnnouncements.island";
|
|
20
20
|
import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
@@ -24,6 +24,9 @@ import LayoutHeader from "./LayoutHeader";
|
|
|
24
24
|
import LayoutRail, { type LayoutAppLink } from "./LayoutRail";
|
|
25
25
|
import type { LayoutContext } from "./layout-context";
|
|
26
26
|
import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
27
|
+
import PageTrailRow from "./PageTrailRow.island";
|
|
28
|
+
import PageTrailRuntime from "./PageTrailRuntime.island";
|
|
29
|
+
import { derivePageTrail } from "./page-trail";
|
|
27
30
|
import { platformMessages } from "./platform-messages";
|
|
28
31
|
import RegisteredHelpDocuments from "./RegisteredHelpDocuments.island";
|
|
29
32
|
import { sortRailApps } from "./rail-navigation";
|
|
@@ -153,7 +156,8 @@ export default function Layout(props: LayoutProps) {
|
|
|
153
156
|
const t = platformMessages.resolve([lang]).t;
|
|
154
157
|
const user = c.get("user");
|
|
155
158
|
const pathname = new URL(c.req.raw.url).pathname;
|
|
156
|
-
const
|
|
159
|
+
const currentRoute = resolveRuntimeRoute(runtime.apps, pathname);
|
|
160
|
+
const currentApp = currentRoute?.app;
|
|
157
161
|
const registeredHelp = getLayoutHelp(c);
|
|
158
162
|
const workspaceLayout = resolveAppWorkspaceLayoutForSidebar(
|
|
159
163
|
readAppWorkspaceLayoutCookie(cookie, currentApp?.id),
|
|
@@ -201,6 +205,13 @@ export default function Layout(props: LayoutProps) {
|
|
|
201
205
|
const pageTitle = typeof title === "string" ? title : (title?.at(-1)?.title ?? appName);
|
|
202
206
|
if (!page.title) page.title = pageTitle;
|
|
203
207
|
const breadcrumbs: Breadcrumb[] = !title ? [{ title: appName }] : typeof title === "string" ? [{ title }] : title;
|
|
208
|
+
// The way up is derived from the title array by one rule (see `derivePageTrail`). Anonymous pages keep their
|
|
209
|
+
// header breadcrumbs, and focus surfaces such as pop-out editors have no way up; both show the title alone. The
|
|
210
|
+
// canvas carries this for `layout.update()`.
|
|
211
|
+
const titleOnly = !user || focusMode;
|
|
212
|
+
const pageTrail = titleOnly
|
|
213
|
+
? { title: pageTitle, ancestors: [] }
|
|
214
|
+
: derivePageTrail(title ?? appName, currentRoute?.prefix, appName, `${pathname}${new URL(c.req.raw.url).search}`);
|
|
204
215
|
const showRail = !!user;
|
|
205
216
|
// The profile menu offers the mobile app only while the installation runs it.
|
|
206
217
|
const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
|
|
@@ -209,9 +220,9 @@ export default function Layout(props: LayoutProps) {
|
|
|
209
220
|
user?.avatarHash && user.id
|
|
210
221
|
? `/api/accounts/users/${encodeURIComponent(user.id)}/avatar?rev=${encodeURIComponent(user.avatarHash)}`
|
|
211
222
|
: undefined;
|
|
212
|
-
// From
|
|
223
|
+
// From md, `main` is the page scroller of regular pages. Its stable gutter
|
|
213
224
|
// keeps short and long pages at the same width where scrollbars take space.
|
|
214
|
-
const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "
|
|
225
|
+
const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "md:overflow-auto md:[scrollbar-gutter:stable]";
|
|
215
226
|
const canvasStyle =
|
|
216
227
|
[appAppearanceStyle(currentApp?.appearance), appWorkspaceLayoutStyle(workspaceLayout), focusMode && flushCanvas ? "padding:0" : ""]
|
|
217
228
|
.filter(Boolean)
|
|
@@ -223,24 +234,34 @@ export default function Layout(props: LayoutProps) {
|
|
|
223
234
|
class="cloud-app-canvas flex h-dvh w-full overflow-hidden"
|
|
224
235
|
style={canvasStyle}
|
|
225
236
|
data-app-id={currentApp?.id}
|
|
237
|
+
data-app-base={currentRoute?.prefix}
|
|
238
|
+
data-page-trail-title-only=""
|
|
226
239
|
data-layout-full-page="true"
|
|
227
240
|
data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
|
|
228
241
|
>
|
|
229
242
|
<TimezoneCookie />
|
|
230
243
|
{registeredHelp && <RegisteredHelpDocuments documents={registeredHelp.documents} pageBase={registeredHelp.pageBase} />}
|
|
231
|
-
<
|
|
232
|
-
<
|
|
233
|
-
|
|
244
|
+
<PageTrailProvider trail={pageTrail}>
|
|
245
|
+
<PageTrailRuntime />
|
|
246
|
+
<main class="min-h-0 min-w-0 flex-1">
|
|
247
|
+
<AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
|
|
248
|
+
</main>
|
|
249
|
+
</PageTrailProvider>
|
|
234
250
|
</div>
|
|
235
251
|
</LocaleProvider>
|
|
236
252
|
);
|
|
237
253
|
}
|
|
254
|
+
// The canvas takes the window's visible height. `vh` follows the large
|
|
255
|
+
// viewport, which is taller than the window while a browser shows its
|
|
256
|
+
// toolbar, and would let a viewport-bound page scroll by that difference.
|
|
238
257
|
return (
|
|
239
258
|
<LocaleProvider locale={lang}>
|
|
240
259
|
<div
|
|
241
|
-
class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-
|
|
260
|
+
class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
|
|
242
261
|
style={canvasStyle}
|
|
243
262
|
data-app-id={currentApp?.id}
|
|
263
|
+
data-app-base={currentRoute?.prefix}
|
|
264
|
+
data-page-trail-title-only={titleOnly ? "" : undefined}
|
|
244
265
|
data-layout-full-page={fullPage ? "true" : undefined}
|
|
245
266
|
data-layout-authenticated={showRail ? "true" : undefined}
|
|
246
267
|
data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
|
|
@@ -279,7 +300,6 @@ export default function Layout(props: LayoutProps) {
|
|
|
279
300
|
launchpadApps={launchpadApps}
|
|
280
301
|
legalLinks={legalLinks}
|
|
281
302
|
openAppsLabel={t.openApps}
|
|
282
|
-
profileAvatarSrc={profileAvatarSrc}
|
|
283
303
|
profileName={profileName}
|
|
284
304
|
searchHelpApps={searchHelpApps}
|
|
285
305
|
searchLinks={searchLinks}
|
|
@@ -296,9 +316,13 @@ export default function Layout(props: LayoutProps) {
|
|
|
296
316
|
)}
|
|
297
317
|
{user && <ProfileWarnings user={user} locale={lang} />}
|
|
298
318
|
{user && <ExpiryWarnings user={user} dateConfig={dateConfig} locale={lang} />}
|
|
299
|
-
<
|
|
300
|
-
<
|
|
301
|
-
|
|
319
|
+
<PageTrailProvider trail={pageTrail}>
|
|
320
|
+
<PageTrailRuntime />
|
|
321
|
+
<PageTrailRow />
|
|
322
|
+
<main class={`layout-content-main min-h-0 min-w-0 flex-1 ${mainLayoutClass}`}>
|
|
323
|
+
<AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
|
|
324
|
+
</main>
|
|
325
|
+
</PageTrailProvider>
|
|
302
326
|
{!fullPage && !showRail && <LayoutFooter appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />}
|
|
303
327
|
</div>
|
|
304
328
|
</div>
|
package/src/ssr/LayoutFooter.tsx
CHANGED
|
@@ -5,7 +5,7 @@ type LayoutFooterProps = {
|
|
|
5
5
|
|
|
6
6
|
export default function LayoutFooter(props: LayoutFooterProps) {
|
|
7
7
|
return (
|
|
8
|
-
<footer class="hidden shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-1 px-3 py-2 text-xs text-dimmed
|
|
8
|
+
<footer class="hidden shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-1 px-3 py-2 text-xs text-dimmed md:flex">
|
|
9
9
|
{props.legalLinks.map((link) => (
|
|
10
10
|
<a href={link.href} class="flex items-center gap-1 transition-colors hover:text-primary">
|
|
11
11
|
{link.icon && <i class={`${link.icon} text-xs`} />}
|
package/src/ssr/LayoutHeader.tsx
CHANGED
|
@@ -8,7 +8,6 @@ import LayoutBreadcrumbs from "./LayoutBreadcrumbs.island";
|
|
|
8
8
|
import LayoutHelpTrigger from "./LayoutHelpTrigger.island";
|
|
9
9
|
import LayoutPreferences from "./LayoutPreferences.island";
|
|
10
10
|
import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
11
|
-
import ProfilePreferences from "./ProfilePreferences.island";
|
|
12
11
|
|
|
13
12
|
type LayoutHeaderProps = {
|
|
14
13
|
accent?: string;
|
|
@@ -21,7 +20,6 @@ type LayoutHeaderProps = {
|
|
|
21
20
|
launchpadApps: AppLaunchpadApp[];
|
|
22
21
|
legalLinks: Array<{ label: string; href: string; icon?: string }>;
|
|
23
22
|
openAppsLabel: string;
|
|
24
|
-
profileAvatarSrc?: string;
|
|
25
23
|
profileName: string;
|
|
26
24
|
searchHelpApps: GlobalSearchHelpApp[];
|
|
27
25
|
searchLinks?: NavigationSearchItem[];
|
|
@@ -29,23 +27,32 @@ type LayoutHeaderProps = {
|
|
|
29
27
|
theme: CloudTheme;
|
|
30
28
|
};
|
|
31
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Phones (below `md`) always get this header. From `md` it remains only on
|
|
32
|
+
* anonymous pages; signed-in people reach Home, search, help, the app grid,
|
|
33
|
+
* and their profile through the rail, so the app area starts at the top edge.
|
|
34
|
+
* Anonymous pages have no rail: up to `lg` their header keeps the app name,
|
|
35
|
+
* help, search, and the app grid, and from `lg` it shows the breadcrumbs.
|
|
36
|
+
*/
|
|
32
37
|
export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
33
38
|
return (
|
|
34
39
|
<header
|
|
35
|
-
class=
|
|
40
|
+
class={`layout-header paper flex min-h-[2.875rem] shrink-0 items-center justify-between px-2 py-1.5 md:px-3 md:py-2 ${props.authenticated ? "md:hidden" : ""}`}
|
|
36
41
|
style="box-shadow: var(--ui-shadow-surface)"
|
|
37
42
|
>
|
|
38
43
|
<div class="flex min-w-0 items-center gap-2">
|
|
39
|
-
{/*
|
|
40
|
-
|
|
41
|
-
<div class=
|
|
44
|
+
{/* A long title must not squeeze the button; `shrink-0` sits on a wrapper because the button's own
|
|
45
|
+
styles would win over a utility on the link. */}
|
|
46
|
+
<div class="flex shrink-0">
|
|
42
47
|
<IconButtonLink href="/" label={props.homeLabel}>
|
|
43
48
|
<img src="/branding/logo" alt="" class={props.authenticated ? "h-4 w-4" : "h-6 w-6"} />
|
|
44
49
|
</IconButtonLink>
|
|
45
50
|
</div>
|
|
46
|
-
|
|
47
|
-
<
|
|
48
|
-
|
|
51
|
+
{!props.authenticated && (
|
|
52
|
+
<div class="hidden min-w-0 items-center lg:flex">
|
|
53
|
+
<LayoutBreadcrumbs breadcrumbs={props.breadcrumbs} />
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
49
56
|
<div class="flex min-w-0 items-center lg:hidden">
|
|
50
57
|
<span class="truncate font-semibold">{props.appLabel ?? props.breadcrumbs.at(-1)?.title}</span>
|
|
51
58
|
</div>
|
|
@@ -78,17 +85,7 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
|
78
85
|
/>
|
|
79
86
|
)}
|
|
80
87
|
</div>
|
|
81
|
-
{props.authenticated
|
|
82
|
-
<div class="hidden lg:block">
|
|
83
|
-
<ProfilePreferences
|
|
84
|
-
appHref={props.appHref}
|
|
85
|
-
avatarSrc={props.profileAvatarSrc}
|
|
86
|
-
initialTheme={props.theme}
|
|
87
|
-
name={props.profileName}
|
|
88
|
-
placement="header"
|
|
89
|
-
/>
|
|
90
|
-
</div>
|
|
91
|
-
) : (
|
|
88
|
+
{!props.authenticated && (
|
|
92
89
|
<>
|
|
93
90
|
<LayoutPreferences initialTheme={props.theme} position="bottom-left" />
|
|
94
91
|
<ButtonLink href="/auth/login" size="sm" variant="primary">
|
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -534,7 +534,7 @@ const HelpShell = (props: {
|
|
|
534
534
|
props.surface === "modal"
|
|
535
535
|
? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
|
|
536
536
|
: props.surface === "page"
|
|
537
|
-
? "min-h-
|
|
537
|
+
? "min-h-dvh"
|
|
538
538
|
: "h-full"
|
|
539
539
|
}`}
|
|
540
540
|
style={appAccentStyle(props.accent)}
|
package/src/ssr/LayoutRail.tsx
CHANGED
|
@@ -43,7 +43,7 @@ export default function LayoutRail(props: LayoutRailProps) {
|
|
|
43
43
|
<script id="cloud-rail-data" type="application/json">
|
|
44
44
|
{jsonScript({ apps: props.apps, settings: props.railSettings })}
|
|
45
45
|
</script>
|
|
46
|
-
<aside class="layout-rail hidden w-10 shrink-0 flex-col
|
|
46
|
+
<aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
|
|
47
47
|
<div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
|
|
48
48
|
<a href="/" aria-label={props.homeLabel}>
|
|
49
49
|
<img src="/branding/logo" alt="Logo" class="h-5 w-5" />
|
|
@@ -66,7 +66,6 @@ export default function LayoutRail(props: LayoutRailProps) {
|
|
|
66
66
|
avatarSrc={props.profileAvatarSrc}
|
|
67
67
|
initialTheme={props.theme}
|
|
68
68
|
name={props.profileName}
|
|
69
|
-
placement="rail"
|
|
70
69
|
/>
|
|
71
70
|
</div>
|
|
72
71
|
</nav>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PageTitle, usePageTrail } from "@k2b/ui";
|
|
2
|
+
import { type JSX, Show } from "solid-js";
|
|
3
|
+
|
|
4
|
+
export type PageTrailProps = {
|
|
5
|
+
/** Quiet text after the title, for example a count. */
|
|
6
|
+
subtitle?: JSX.Element;
|
|
7
|
+
/** Defaults to `"h1"`. */
|
|
8
|
+
as?: "h1" | "h2" | "p";
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The page's title with its way up, for an app that draws its own header.
|
|
14
|
+
* It shows what the shell would show, from the same title array (`Layout`'s
|
|
15
|
+
* `title`, kept current by `layout.update()`), and takes the shell's place:
|
|
16
|
+
* workspace heads and the shell's row step back while it is on the page.
|
|
17
|
+
*/
|
|
18
|
+
export default function PageTrail(props: PageTrailProps) {
|
|
19
|
+
const trail = usePageTrail();
|
|
20
|
+
return (
|
|
21
|
+
<Show when={trail()}>{(current) => <PageTitle trail={current()} subtitle={props.subtitle} as={props.as} class={props.class} />}</Show>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PageTitle, usePageTrail } from "@k2b/ui";
|
|
2
|
+
import { Show } from "solid-js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The shell's own place for the way up, above pages without a workspace. It
|
|
6
|
+
* shows only on a page with a page above it, and steps back when the page
|
|
7
|
+
* renders a workspace (whose head shows it) or its own `PageTrail`.
|
|
8
|
+
*/
|
|
9
|
+
export default function PageTrailRow() {
|
|
10
|
+
const trail = usePageTrail();
|
|
11
|
+
const deep = () => {
|
|
12
|
+
const current = trail();
|
|
13
|
+
return current?.ancestors.length ? current : undefined;
|
|
14
|
+
};
|
|
15
|
+
return (
|
|
16
|
+
<div class="layout-page-head" data-trail={deep() ? "up" : undefined}>
|
|
17
|
+
<Show when={deep()}>{(current) => <PageTitle trail={current()} as="p" fallback />}</Show>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { followPageTrail, installPageTitleController, useLocale } from "@k2b/ui";
|
|
2
|
+
import { onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { installPageTrailRuntime } from "./page-trail-runtime";
|
|
4
|
+
import { platformMessages } from "./platform-messages";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One owner per document for the way up: it collapses every page title path
|
|
8
|
+
* to its space, keeps server-rendered workspace heads current after
|
|
9
|
+
* `layout.update()`, runs the per-tab memory behind up links, and offers
|
|
10
|
+
* "One level up" (Alt+↑) while the page has a page above it.
|
|
11
|
+
*/
|
|
12
|
+
export default function PageTrailRuntime() {
|
|
13
|
+
const locale = useLocale();
|
|
14
|
+
onMount(() => {
|
|
15
|
+
const t = platformMessages.resolve([locale()]).t;
|
|
16
|
+
const disposeTitles = installPageTitleController(document);
|
|
17
|
+
const disposeFollow = followPageTrail(document);
|
|
18
|
+
const disposeRuntime = installPageTrailRuntime((page) => ({ title: t.pageUp({ page }), description: t.pageUpDescription }));
|
|
19
|
+
onCleanup(() => {
|
|
20
|
+
disposeTitles();
|
|
21
|
+
disposeFollow();
|
|
22
|
+
disposeRuntime();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -8,16 +8,20 @@ type ProfilePreferencesProps = {
|
|
|
8
8
|
avatarSrc?: string;
|
|
9
9
|
initialTheme: CloudTheme;
|
|
10
10
|
name: string;
|
|
11
|
-
placement: "header" | "rail";
|
|
12
11
|
};
|
|
13
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The avatar at the bottom of the rail. Its menu opens beside the rail and,
|
|
15
|
+
* because the avatar is the rail's last control, grows upward from the window's
|
|
16
|
+
* bottom edge.
|
|
17
|
+
*/
|
|
14
18
|
export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
15
19
|
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
|
|
16
20
|
|
|
17
21
|
const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="xs" />;
|
|
18
22
|
|
|
19
23
|
return (
|
|
20
|
-
<div class="layout-profile-preferences"
|
|
24
|
+
<div class="layout-profile-preferences">
|
|
21
25
|
<button type="button" class="layout-profile-preferences__link" aria-label={preferences.messages().menuLabel}>
|
|
22
26
|
{avatar()}
|
|
23
27
|
</button>
|
|
@@ -49,7 +53,7 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
|
49
53
|
class="layout-profile-preferences__dropdown"
|
|
50
54
|
items={items()}
|
|
51
55
|
label={preferences.messages().menuLabel}
|
|
52
|
-
position=
|
|
56
|
+
position="right-start"
|
|
53
57
|
width="14rem"
|
|
54
58
|
>
|
|
55
59
|
<Dropdown.Trigger
|
package/src/ssr/islands/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { default as PageTrail, type PageTrailProps } from "../PageTrail";
|
|
1
2
|
export { default as WorkspaceNavigationProvider } from "../WorkspaceNavigationProvider";
|
|
2
3
|
export { provideWorkspaceNavigation } from "../workspace-navigation";
|
|
3
4
|
export { default as SearchBar } from "./SearchBar";
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readPageTrail, setPageTrail } from "@k2b/ui";
|
|
2
|
+
import { derivePageTrail, dropSelfLinks } from "./page-trail";
|
|
3
|
+
|
|
1
4
|
export type LayoutBreadcrumb = {
|
|
2
5
|
title: string;
|
|
3
6
|
href?: string;
|
|
@@ -10,13 +13,70 @@ export type LayoutUpdate = {
|
|
|
10
13
|
|
|
11
14
|
export const LAYOUT_UPDATE_EVENT = "cloud:layout:update";
|
|
12
15
|
|
|
16
|
+
/** The canvas attribute that carries the current app's base path, for the trail rule in the browser. */
|
|
17
|
+
export const APP_BASE_ATTRIBUTE = "data-app-base";
|
|
18
|
+
/** Set on the canvas of a page that shows its title without a way up: anonymous and focus-mode pages. */
|
|
19
|
+
const TITLE_ONLY_ATTRIBUTE = "data-page-trail-title-only";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Window event before the way up (a trail link or Alt+↑) loads a page. Its
|
|
23
|
+
* `detail.href` is where it leads: the page above, as this tab left it.
|
|
24
|
+
* Cancelling it keeps the document; the listener then navigates itself.
|
|
25
|
+
*/
|
|
26
|
+
export const PAGE_UP_EVENT = "cloud:page-up";
|
|
27
|
+
export type PageUpDetail = { href: string };
|
|
28
|
+
|
|
29
|
+
const here = () => `${location.pathname}${location.search}`;
|
|
30
|
+
|
|
13
31
|
export const layout = {
|
|
32
|
+
/**
|
|
33
|
+
* Updates the page after an in-page navigation: the document title, and the
|
|
34
|
+
* page title with its way up wherever the shell or `PageTrail` shows it.
|
|
35
|
+
* `breadcrumbs` is the same title array `Layout` takes, under the same rule;
|
|
36
|
+
* `title` alone renames the current page and keeps the levels above it.
|
|
37
|
+
*/
|
|
14
38
|
update(update: LayoutUpdate) {
|
|
15
39
|
if (typeof window === "undefined") return;
|
|
16
40
|
|
|
17
41
|
const title = update.title ?? update.breadcrumbs?.at(-1)?.title;
|
|
18
42
|
if (title) document.title = title;
|
|
43
|
+
const canvas = document.querySelector(".cloud-app-canvas");
|
|
44
|
+
const base = canvas?.getAttribute(APP_BASE_ATTRIBUTE) ?? undefined;
|
|
45
|
+
const current = readPageTrail();
|
|
46
|
+
const derived = update.breadcrumbs?.length
|
|
47
|
+
? derivePageTrail(update.breadcrumbs, base, title ?? current?.title ?? document.title)
|
|
48
|
+
: title && current
|
|
49
|
+
? { ...current, title }
|
|
50
|
+
: undefined;
|
|
51
|
+
if (!derived) return;
|
|
52
|
+
const trail = update.title ? { ...derived, title: update.title } : derived;
|
|
53
|
+
setPageTrail(canvas?.hasAttribute(TITLE_ONLY_ATTRIBUTE) ? { ...trail, ancestors: [] } : trail);
|
|
54
|
+
if (update.breadcrumbs?.length) {
|
|
55
|
+
// An island may change the address right after this call, in the same task, so the link to the page itself
|
|
56
|
+
// is known only then. Comparing earlier would drop the page the island has just left.
|
|
57
|
+
queueMicrotask(() => {
|
|
58
|
+
const published = readPageTrail();
|
|
59
|
+
if (!published) return;
|
|
60
|
+
const settled = dropSelfLinks(published, here());
|
|
61
|
+
if (settled !== published) setPageTrail(settled);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
19
64
|
|
|
20
65
|
window.dispatchEvent(new CustomEvent<LayoutUpdate>(LAYOUT_UPDATE_EVENT, { detail: update }));
|
|
21
66
|
},
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Lets an island that navigates in place, such as a file browser, take the
|
|
70
|
+
* way up itself instead of a page load that would end its running work.
|
|
71
|
+
* `go` gets the destination and returns `true` when it goes there in place.
|
|
72
|
+
* Returns the disposer.
|
|
73
|
+
*/
|
|
74
|
+
handleUp(go: (href: string) => boolean): () => void {
|
|
75
|
+
if (typeof window === "undefined") return () => {};
|
|
76
|
+
const listener = (event: Event) => {
|
|
77
|
+
if (!event.defaultPrevented && go((event as CustomEvent<PageUpDetail>).detail.href)) event.preventDefault();
|
|
78
|
+
};
|
|
79
|
+
window.addEventListener(PAGE_UP_EVENT, listener);
|
|
80
|
+
return () => window.removeEventListener(PAGE_UP_EVENT, listener);
|
|
81
|
+
},
|
|
22
82
|
};
|
|
@@ -18,7 +18,7 @@ export function openMobileMenu(view: (beforeSelect: () => Promise<boolean>) => D
|
|
|
18
18
|
history.pushState({ ...previous, [marker]: id }, "");
|
|
19
19
|
const abort = new AbortController();
|
|
20
20
|
let leavingPage = false;
|
|
21
|
-
const mobile = window.matchMedia("(max-width:
|
|
21
|
+
const mobile = window.matchMedia("(max-width: 767px)");
|
|
22
22
|
const pop = () => {
|
|
23
23
|
if (history.state?.[marker] !== id) abort.abort();
|
|
24
24
|
};
|