@k2b/cloud 0.22.0 → 0.24.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 +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/migrate.ts +35 -16
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +830 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +70 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -108
|
@@ -3,6 +3,8 @@ import type { CloudTheme } from "../shared/theme";
|
|
|
3
3
|
import { createProfileActions } from "./profile-actions";
|
|
4
4
|
|
|
5
5
|
type ProfilePreferencesProps = {
|
|
6
|
+
/** Set while the installation offers the mobile app. */
|
|
7
|
+
appHref?: string;
|
|
6
8
|
avatarSrc?: string;
|
|
7
9
|
initialTheme: CloudTheme;
|
|
8
10
|
name: string;
|
|
@@ -10,7 +12,7 @@ type ProfilePreferencesProps = {
|
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
13
|
-
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme);
|
|
15
|
+
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
|
|
14
16
|
|
|
15
17
|
const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="xs" />;
|
|
16
18
|
|
|
@@ -32,6 +34,12 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
|
32
34
|
<i class="ti ti-user-circle" aria-hidden="true" />
|
|
33
35
|
<span>{preferences.messages().profileSettings}</span>
|
|
34
36
|
</a>
|
|
37
|
+
{props.appHref && (
|
|
38
|
+
<a href={props.appHref} class="menu-item">
|
|
39
|
+
<i class="ti ti-device-mobile" aria-hidden="true" />
|
|
40
|
+
<span>{preferences.messages().app}</span>
|
|
41
|
+
</a>
|
|
42
|
+
)}
|
|
35
43
|
<button type="button" class="menu-item" onClick={signOut} disabled={signingOut()}>
|
|
36
44
|
<i class="ti ti-logout" aria-hidden="true" />
|
|
37
45
|
<span>{preferences.messages().signOut}</span>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { LocaleProvider, MobileShell, TabBar, type TabBarItem } from "@k2b/ui";
|
|
2
|
+
import type { JSX } from "solid-js";
|
|
3
|
+
import { PWA_SCOPE } from "../contracts/pwa";
|
|
4
|
+
import { getLocale } from "../server/locale";
|
|
5
|
+
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
6
|
+
import { visiblePwaParts } from "./app-navigation";
|
|
7
|
+
import type { LayoutContext } from "./layout-context";
|
|
8
|
+
import PwaRuntime from "./PwaRuntime.island";
|
|
9
|
+
import { pwaMessages } from "./pwa-messages";
|
|
10
|
+
import { getRuntimeContext } from "./runtime";
|
|
11
|
+
import TimezoneCookie from "./TimezoneCookie.island";
|
|
12
|
+
|
|
13
|
+
/** Parts with their own tab; Start lists every part and doubles as "More". */
|
|
14
|
+
const TAB_PARTS = 3;
|
|
15
|
+
|
|
16
|
+
export type PwaLayoutProps = {
|
|
17
|
+
/** The request context; the layout also sets the page's framing and referrer headers. */
|
|
18
|
+
c: LayoutContext & { header(name: string, value: string): void };
|
|
19
|
+
/** Header heading and document title. */
|
|
20
|
+
title: string;
|
|
21
|
+
/** Up-navigation inside the part, for example from a detail back to its list. */
|
|
22
|
+
back?: { href: string; label: string };
|
|
23
|
+
/** At most two icon buttons at the header end. */
|
|
24
|
+
actions?: JSX.Element;
|
|
25
|
+
children: JSX.Element;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The frame of every page in the installable mobile app (preview): the app's document head, a phone shell with a
|
|
30
|
+
* tab bar of Start and the first parts, and the browser runtime (service worker, offline notice, session keepalive).
|
|
31
|
+
* Pages render it below `/pwa/` with `ssr.pwaAccess`.
|
|
32
|
+
*/
|
|
33
|
+
export default function PwaLayout(props: PwaLayoutProps) {
|
|
34
|
+
const { c } = props;
|
|
35
|
+
const locale = getLocale(c);
|
|
36
|
+
const t = pwaMessages.resolve([locale]).t;
|
|
37
|
+
const user = c.get("user");
|
|
38
|
+
const cloud = c.get("settings")?.app?.name || "Cloud";
|
|
39
|
+
const page = c.get("page");
|
|
40
|
+
page.theme = readThemeFromCookieHeader(c.req.raw.headers.get("Cookie"));
|
|
41
|
+
page.title = props.title;
|
|
42
|
+
page.pwa = { name: cloud };
|
|
43
|
+
// No app page may be framed, and none leaks its address to another site.
|
|
44
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
45
|
+
c.header("Content-Security-Policy", "frame-ancestors 'none'");
|
|
46
|
+
|
|
47
|
+
const parts = visiblePwaParts(getRuntimeContext(c).apps, user, locale);
|
|
48
|
+
const path = new URL(c.req.raw.url).pathname;
|
|
49
|
+
const tabs = parts.slice(0, TAB_PARTS);
|
|
50
|
+
const currentTab = tabs.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
|
|
51
|
+
// Start is titled with the installation's name; a part's first page carries the part's name.
|
|
52
|
+
const items: TabBarItem[] = [
|
|
53
|
+
{ id: "start", label: t.start, title: cloud, icon: "ti ti-home", href: PWA_SCOPE, current: !currentTab },
|
|
54
|
+
...tabs.map((part) => ({ id: part.id, label: part.name, icon: part.icon, href: part.pwa.href, current: part === currentTab })),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<LocaleProvider locale={locale}>
|
|
59
|
+
{/* Pages without a person show no times. Their pairing link lives only in memory, and a reload would lose it. */}
|
|
60
|
+
<TimezoneCookie reload={!!user} />
|
|
61
|
+
<MobileShell
|
|
62
|
+
header={<MobileShell.Header title={props.title} back={props.back} actions={props.actions} />}
|
|
63
|
+
footer={user && parts.length > 0 ? <TabBar label={t.tabs} items={items} /> : undefined}
|
|
64
|
+
>
|
|
65
|
+
{props.children}
|
|
66
|
+
</MobileShell>
|
|
67
|
+
<PwaRuntime keepalive={!!user} cloud={cloud} />
|
|
68
|
+
</LocaleProvider>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { observeMobileShell, syncThemeColor, type ToastHandle, toast, useLocale } from "@k2b/ui";
|
|
2
|
+
import { onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { renewAppSession } from "../browser/app-session";
|
|
4
|
+
import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa";
|
|
5
|
+
import { pwaMessages } from "./pwa-messages";
|
|
6
|
+
|
|
7
|
+
/** Set once per browsing session, so the first page renews and later navigations do not. */
|
|
8
|
+
const RENEWED_MARKER = "cloud.pwa.renewed";
|
|
9
|
+
const KEEPALIVE_INTERVAL_MS = 60 * 60 * 1000;
|
|
10
|
+
/** Matches `cloud:theme-preference` in the preference controller. */
|
|
11
|
+
const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
|
|
12
|
+
|
|
13
|
+
export type PwaRuntimeProps = {
|
|
14
|
+
/** A person uses the app: keep the app session alive. */
|
|
15
|
+
keepalive: boolean;
|
|
16
|
+
/** The installation name, for the account notice. */
|
|
17
|
+
cloud: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const firstPageOfSession = (): boolean => {
|
|
21
|
+
try {
|
|
22
|
+
if (window.sessionStorage.getItem(RENEWED_MARKER)) return false;
|
|
23
|
+
window.sessionStorage.setItem(RENEWED_MARKER, "1");
|
|
24
|
+
} catch {}
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The browser side of every mobile app page: the service worker, the offline notice, the app session keepalive,
|
|
30
|
+
* the status bar colour after a theme change, and the server-rendered shell's measurements.
|
|
31
|
+
*/
|
|
32
|
+
export default function PwaRuntime(props: PwaRuntimeProps) {
|
|
33
|
+
const locale = useLocale();
|
|
34
|
+
const t = () => pwaMessages.resolve([locale()]).t;
|
|
35
|
+
|
|
36
|
+
onMount(() => {
|
|
37
|
+
const cleanups: (() => void)[] = [];
|
|
38
|
+
onCleanup(() => {
|
|
39
|
+
for (const cleanup of cleanups) cleanup();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const shell = document.querySelector<HTMLElement>(".k2b-mobile-shell");
|
|
43
|
+
if (shell) cleanups.push(observeMobileShell(shell));
|
|
44
|
+
|
|
45
|
+
// The launch bounce marks its return; the page is here now, so the marker leaves the address.
|
|
46
|
+
const url = new URL(location.href);
|
|
47
|
+
if (url.searchParams.has("pwa_launch")) {
|
|
48
|
+
url.searchParams.delete("pwa_launch");
|
|
49
|
+
history.replaceState(history.state, "", url.pathname + url.search + url.hash);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Registration fails in private windows and insecure contexts; the app then simply works online only.
|
|
53
|
+
void navigator.serviceWorker?.register(PWA_SERVICE_WORKER_PATH, { scope: PWA_SCOPE, updateViaCache: "none" }).catch(() => undefined);
|
|
54
|
+
|
|
55
|
+
let offlineToast: ToastHandle | undefined;
|
|
56
|
+
const offline = () => {
|
|
57
|
+
offlineToast ??= toast(t().offline, { duration: 0, iconClass: "ti ti-wifi-off" });
|
|
58
|
+
};
|
|
59
|
+
const online = () => {
|
|
60
|
+
offlineToast?.dismiss();
|
|
61
|
+
offlineToast = undefined;
|
|
62
|
+
};
|
|
63
|
+
if (!navigator.onLine) offline();
|
|
64
|
+
window.addEventListener("offline", offline);
|
|
65
|
+
window.addEventListener("online", online);
|
|
66
|
+
cleanups.push(() => {
|
|
67
|
+
window.removeEventListener("offline", offline);
|
|
68
|
+
window.removeEventListener("online", online);
|
|
69
|
+
offlineToast?.dismiss();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const syncTheme = () => syncThemeColor();
|
|
73
|
+
window.addEventListener(THEME_PREFERENCE_EVENT, syncTheme);
|
|
74
|
+
cleanups.push(() => window.removeEventListener(THEME_PREFERENCE_EVENT, syncTheme));
|
|
75
|
+
|
|
76
|
+
if (!props.keepalive) return;
|
|
77
|
+
|
|
78
|
+
let otherAccountShown = false;
|
|
79
|
+
let disposed = false;
|
|
80
|
+
cleanups.push(() => {
|
|
81
|
+
disposed = true;
|
|
82
|
+
});
|
|
83
|
+
const renew = async () => {
|
|
84
|
+
const { otherAccount } = await renewAppSession();
|
|
85
|
+
if (disposed || !otherAccount || otherAccountShown) return;
|
|
86
|
+
otherAccountShown = true;
|
|
87
|
+
toast(t().otherAccount({ cloud: props.cloud, name: otherAccount.name }), { duration: 0, iconClass: "ti ti-alert-triangle" });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (firstPageOfSession()) void renew();
|
|
91
|
+
const visible = () => {
|
|
92
|
+
if (document.visibilityState === "visible") void renew();
|
|
93
|
+
};
|
|
94
|
+
document.addEventListener("visibilitychange", visible);
|
|
95
|
+
const timer = setInterval(() => {
|
|
96
|
+
if (document.visibilityState === "visible") void renew();
|
|
97
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
98
|
+
cleanups.push(() => {
|
|
99
|
+
document.removeEventListener("visibilitychange", visible);
|
|
100
|
+
clearInterval(timer);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
@@ -2,12 +2,18 @@ import { cookies } from "@k2b/stdlib/browser";
|
|
|
2
2
|
import { onMount } from "solid-js";
|
|
3
3
|
import { TIMEZONE_COOKIE } from "../shared/time";
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export type TimezoneCookieProps = {
|
|
6
|
+
/** Reload once after a timezone change, so the page shows times in the browser's zone. Defaults to `true`. */
|
|
7
|
+
reload?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function TimezoneCookie(props: TimezoneCookieProps) {
|
|
6
11
|
onMount(() => {
|
|
7
12
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
8
13
|
const current = cookies.readCookie(TIMEZONE_COOKIE);
|
|
9
14
|
if (timeZone && current !== timeZone) {
|
|
10
15
|
cookies.writeCookie(TIMEZONE_COOKIE, timeZone);
|
|
16
|
+
if (props.reload === false) return;
|
|
11
17
|
try {
|
|
12
18
|
if (sessionStorage.getItem("cloud.timezone.reload") !== timeZone) {
|
|
13
19
|
sessionStorage.setItem("cloud.timezone.reload", timeZone);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { RuntimeAppMeta } from "../contracts/app";
|
|
2
|
-
import {
|
|
2
|
+
import { isPwaPartId } from "../contracts/pwa";
|
|
3
|
+
import { hasRole, type Role, type User } from "../contracts/shared";
|
|
4
|
+
import { resolveAppPresentation } from "../shared/app-presentation";
|
|
3
5
|
|
|
4
6
|
export type VisibleNavigationApp = RuntimeAppMeta & {
|
|
5
7
|
nav: NonNullable<RuntimeAppMeta["nav"]>;
|
|
@@ -25,17 +27,30 @@ export const hasDedicatedRuntimeRoute = (apps: readonly RuntimeAppMeta[], pathna
|
|
|
25
27
|
return !!match && match.app.id !== currentAppId;
|
|
26
28
|
};
|
|
27
29
|
|
|
30
|
+
const hasAnyRole = (user: User | undefined, roles: readonly Role[] | undefined): boolean =>
|
|
31
|
+
!roles || (!!user && roles.some((role) => (role === "guest" ? user.profile === "guest" : hasRole(user, role))));
|
|
32
|
+
|
|
28
33
|
/** Apps rendered in the navigation for a given authenticated user. */
|
|
29
34
|
export const visibleNavigationApps = (apps: readonly RuntimeAppMeta[], user: User | undefined): VisibleNavigationApp[] =>
|
|
30
35
|
apps.filter(
|
|
31
36
|
(app): app is VisibleNavigationApp =>
|
|
32
|
-
!!app.nav &&
|
|
33
|
-
app.nav.section !== "hidden" &&
|
|
34
|
-
(!app.nav.requiresAuth || !!user) &&
|
|
35
|
-
(!app.nav.requiresRoles ||
|
|
36
|
-
(!!user &&
|
|
37
|
-
app.nav.requiresRoles.some((role) => {
|
|
38
|
-
if (role === "guest") return user.profile === "guest";
|
|
39
|
-
return hasRole(user, role);
|
|
40
|
-
}))),
|
|
37
|
+
!!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) && hasAnyRole(user, app.nav.requiresRoles),
|
|
41
38
|
);
|
|
39
|
+
|
|
40
|
+
export type VisiblePwaPart = RuntimeAppMeta & { pwa: NonNullable<RuntimeAppMeta["pwa"]> };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parts of the mobile app the user may see, localized and sorted by name. Empty without a user. The roles are
|
|
44
|
+
* coarse visibility like the navigation's; part routes and services still authorize.
|
|
45
|
+
*/
|
|
46
|
+
export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | undefined, locale: string): VisiblePwaPart[] => {
|
|
47
|
+
if (!user) return [];
|
|
48
|
+
// Like the gateway, which routes /pwa/<id> only to a valid part id that claims it.
|
|
49
|
+
return apps
|
|
50
|
+
.filter(
|
|
51
|
+
(app): app is VisiblePwaPart =>
|
|
52
|
+
!!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) && hasAnyRole(user, app.pwa.requiresRoles),
|
|
53
|
+
)
|
|
54
|
+
.map((app) => resolveAppPresentation(app, locale))
|
|
55
|
+
.sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
|
|
56
|
+
};
|
package/src/ssr/index.ts
CHANGED
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
// Do NOT import from this barrel in .island.tsx or .client.tsx files!
|
|
3
3
|
|
|
4
4
|
export { default as AdminLayout } from "./AdminLayout";
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
hasDedicatedRuntimeRoute,
|
|
7
|
+
resolveRuntimeRoute,
|
|
8
|
+
type VisiblePwaPart,
|
|
9
|
+
visibleNavigationApps,
|
|
10
|
+
visiblePwaParts,
|
|
11
|
+
} from "./app-navigation";
|
|
6
12
|
export { default as Layout } from "./Layout";
|
|
7
13
|
export {
|
|
8
14
|
default as MinimalLayout,
|
|
9
15
|
type MinimalLayoutPreferencePosition,
|
|
10
16
|
type MinimalLayoutProps,
|
|
11
17
|
} from "./MinimalLayout";
|
|
18
|
+
export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
|
|
12
19
|
export { requestPath } from "./request-path";
|
|
13
20
|
export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
|
|
14
21
|
export type { UrlFilterField } from "./url-filter";
|
|
@@ -6,7 +6,7 @@ import type { RuntimeContext } from "./runtime";
|
|
|
6
6
|
export type LayoutContext = {
|
|
7
7
|
get(key: "user"): User | undefined;
|
|
8
8
|
get(key: "railPreferences"): RailSnapshot | undefined;
|
|
9
|
-
get(key: "page"): { theme?: "light" | "dark" };
|
|
9
|
+
get(key: "page"): { theme?: "light" | "dark"; title?: string; pwa?: { name: string } };
|
|
10
10
|
get(key: "runtime"): RuntimeContext;
|
|
11
11
|
get(key: "announcements"): LayoutAnnouncementsState | undefined;
|
|
12
12
|
/** Every application settings snapshot includes Core's app metadata. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DropdownItem } from "@k2b/ui";
|
|
2
2
|
import { createMemo, createSignal, onCleanup, onMount } from "solid-js";
|
|
3
|
-
import { profilePreferenceLocale, setLocalePreference } from "../
|
|
3
|
+
import { profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
|
|
4
4
|
import { type CloudTheme, getCurrentThemePreference, setThemePreference } from "../shared/theme";
|
|
5
5
|
import { profilePreferencesMessages } from "./profile-preferences-messages";
|
|
6
6
|
|
|
@@ -40,5 +40,5 @@ export const createPreferenceController = (initialTheme: CloudTheme, locale: ()
|
|
|
40
40
|
},
|
|
41
41
|
]);
|
|
42
42
|
|
|
43
|
-
return { items, languageLabel, messages, theme, themeLabel, toggleLanguage, toggleTheme };
|
|
43
|
+
return { items, language, languageLabel, messages, theme, themeLabel, toggleLanguage, toggleTheme };
|
|
44
44
|
};
|
|
@@ -5,7 +5,8 @@ import { apiClient } from "../clients/core";
|
|
|
5
5
|
import type { CloudTheme } from "../shared/theme";
|
|
6
6
|
import { createPreferenceController } from "./preference-controller";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** `appHref` is set while the installation offers the mobile app; the item then leads to `/me/app`. */
|
|
9
|
+
export function createProfileActions(initialTheme: CloudTheme, appHref?: string) {
|
|
9
10
|
const locale = useLocale();
|
|
10
11
|
const preferences = createPreferenceController(initialTheme, locale);
|
|
11
12
|
const [signingOut, setSigningOut] = createSignal(false);
|
|
@@ -30,6 +31,7 @@ export function createProfileActions(initialTheme: CloudTheme) {
|
|
|
30
31
|
icon: "ti ti-user-circle",
|
|
31
32
|
label: preferences.messages().profileSettings,
|
|
32
33
|
},
|
|
34
|
+
...(appHref ? [{ href: appHref, icon: "ti ti-device-mobile", label: preferences.messages().app }] : []),
|
|
33
35
|
{
|
|
34
36
|
action: signOut,
|
|
35
37
|
icon: "ti ti-logout",
|
|
@@ -9,6 +9,7 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
9
9
|
language: "Language",
|
|
10
10
|
legalLinks: "Legal",
|
|
11
11
|
profileSettings: "Profile settings",
|
|
12
|
+
app: "App",
|
|
12
13
|
signOut: "Sign out",
|
|
13
14
|
signOutFailed: "Sign out failed. Please try again.",
|
|
14
15
|
switchToDark: "Dark mode",
|
|
@@ -22,6 +23,7 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
22
23
|
language: "Sprache",
|
|
23
24
|
legalLinks: "Rechtliches",
|
|
24
25
|
profileSettings: "Profileinstellungen",
|
|
26
|
+
app: "App",
|
|
25
27
|
signOut: "Abmelden",
|
|
26
28
|
signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
|
|
27
29
|
switchToDark: "Dunkler Modus",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
/** Strings of the mobile app's frame: the tab bar, the offline notice and the account notice. */
|
|
4
|
+
export const pwaMessages = i18n.define({
|
|
5
|
+
baseLocale: "en",
|
|
6
|
+
messages: {
|
|
7
|
+
en: {
|
|
8
|
+
tabs: "App",
|
|
9
|
+
start: "Start",
|
|
10
|
+
offline: "You're offline",
|
|
11
|
+
otherAccount: ({ cloud, name }: { cloud: string; name: string }) =>
|
|
12
|
+
`Chrome on this phone is signed in to ${cloud} as ${name}. Changes in the app may be saved to that account. Sign out of ${cloud} in Chrome.`,
|
|
13
|
+
errorAction: "Back to Start",
|
|
14
|
+
},
|
|
15
|
+
de: {
|
|
16
|
+
tabs: "App",
|
|
17
|
+
start: "Start",
|
|
18
|
+
offline: "Du bist offline",
|
|
19
|
+
otherAccount: ({ cloud, name }: { cloud: string; name: string }) =>
|
|
20
|
+
`Chrome auf diesem Telefon ist bei ${cloud} als ${name} angemeldet. Änderungen in der App werden womöglich in diesem Konto gespeichert. Melde dich in Chrome von ${cloud} ab.`,
|
|
21
|
+
errorAction: "Zurück zum Start",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
package/src/styles/global.css
CHANGED
|
@@ -239,6 +239,12 @@ body {
|
|
|
239
239
|
color: rgb(244 244 245); /* zinc-100 */
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/* Pages of the mobile app paint the shell canvas, which their theme-color and
|
|
243
|
+
html background repeat for the status bar. */
|
|
244
|
+
html:has(.k2b-mobile-shell) body {
|
|
245
|
+
background-color: var(--k2b-surface-canvas);
|
|
246
|
+
}
|
|
247
|
+
|
|
242
248
|
.dark {
|
|
243
249
|
color-scheme: dark;
|
|
244
250
|
--color-selection-bg: rgb(59 130 246 / 0.5);
|
|
@@ -214,6 +214,24 @@
|
|
|
214
214
|
color: var(--k2b-text-muted);
|
|
215
215
|
font-size: 0.8125rem;
|
|
216
216
|
}
|
|
217
|
+
/* Apps still searching and apps that did not answer: one calm line each, below the results. */
|
|
218
|
+
.cloud-resource-search__status {
|
|
219
|
+
display: grid;
|
|
220
|
+
gap: 0.125rem;
|
|
221
|
+
padding: 0 1.125rem 1.5rem;
|
|
222
|
+
color: var(--k2b-text-muted);
|
|
223
|
+
font-size: 0.8125rem;
|
|
224
|
+
}
|
|
225
|
+
.cloud-resource-search__status p {
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-wrap: wrap;
|
|
228
|
+
align-items: center;
|
|
229
|
+
gap: 0.375rem;
|
|
230
|
+
min-height: 1.75rem;
|
|
231
|
+
}
|
|
232
|
+
.cloud-resource-search__status i {
|
|
233
|
+
font-size: 0.875rem;
|
|
234
|
+
}
|
|
217
235
|
.cloud-resource-search__tag-heading {
|
|
218
236
|
display: flex;
|
|
219
237
|
align-items: center;
|
|
@@ -386,6 +404,7 @@
|
|
|
386
404
|
padding-inline: 0;
|
|
387
405
|
}
|
|
388
406
|
.cloud-resource-search__hint,
|
|
407
|
+
.cloud-resource-search__status,
|
|
389
408
|
.cloud-resource-search__idle,
|
|
390
409
|
.cloud-resource-search__footer,
|
|
391
410
|
.cloud-resource-search__count,
|
|
@@ -237,8 +237,8 @@
|
|
|
237
237
|
}
|
|
238
238
|
.k2b-ui .cloud-announcement > .k2b-icon-button {
|
|
239
239
|
position: absolute;
|
|
240
|
-
top: 0.
|
|
241
|
-
right: 0.
|
|
240
|
+
top: 0.875rem;
|
|
241
|
+
right: 0.875rem;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
/* Approval prose follows the surrounding review fields, not document typography. */
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { cp, mkdir } from "node:fs/promises";
|
|
2
|
-
import { dirname, resolve } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
|
|
6
|
-
/** Package the decoder and native assets only for bundles importing PDF vision. */
|
|
7
|
-
export async function buildPdfRenderer(dist: string) {
|
|
8
|
-
const framework = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
-
const out = resolve(dist, "pdf-render");
|
|
10
|
-
await mkdir(out, { recursive: true });
|
|
11
|
-
const worker = await Bun.build({
|
|
12
|
-
entrypoints: [resolve(framework, "src/ai/pdf-render-worker.ts")],
|
|
13
|
-
target: "bun",
|
|
14
|
-
outdir: out,
|
|
15
|
-
naming: "worker.js",
|
|
16
|
-
external: ["pdfjs-dist", "@napi-rs/canvas"],
|
|
17
|
-
});
|
|
18
|
-
if (!worker.success) throw new Error(`PDF decoder build failed: ${worker.logs.join("\n")}`);
|
|
19
|
-
for (const name of ["pdfjs-dist", "@napi-rs/canvas"]) {
|
|
20
|
-
const source = dirname(Bun.resolveSync(`${name}/package.json`, framework));
|
|
21
|
-
await cp(source, resolve(out, "node_modules", name), { recursive: true, dereference: true });
|
|
22
|
-
if (name !== "@napi-rs/canvas") continue;
|
|
23
|
-
const manifest = z
|
|
24
|
-
.object({ optionalDependencies: z.record(z.string(), z.string()) })
|
|
25
|
-
.parse(await Bun.file(resolve(source, "package.json")).json());
|
|
26
|
-
let nativePackages = 0;
|
|
27
|
-
for (const dependency of Object.keys(manifest.optionalDependencies)) {
|
|
28
|
-
let location: string;
|
|
29
|
-
try {
|
|
30
|
-
location = dirname(Bun.resolveSync(`${dependency}/package.json`, source));
|
|
31
|
-
} catch {
|
|
32
|
-
continue;
|
|
33
|
-
} // Only the installed target-platform binary is needed.
|
|
34
|
-
await cp(location, resolve(out, "node_modules", dependency), { recursive: true, dereference: true });
|
|
35
|
-
nativePackages++;
|
|
36
|
-
}
|
|
37
|
-
if (!nativePackages) throw new Error("PDF decoder needs an installed native canvas binary for the build platform.");
|
|
38
|
-
}
|
|
39
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createLiveWebSocket,
|
|
3
|
-
type LiveWebSocket,
|
|
4
|
-
type LiveWebSocketClose,
|
|
5
|
-
type LiveWebSocketError,
|
|
6
|
-
type LiveWebSocketStatus,
|
|
7
|
-
} from "../../browser/live-websocket";
|
|
8
|
-
import { AI_LIVE_WS_TYPE, type AiLiveServerMessage, parseAiLiveServerMessage } from "../live-events";
|
|
9
|
-
import type { AiConversationStreamTransport, AiStreamConnectionStatus } from "./transport";
|
|
10
|
-
|
|
11
|
-
type TurnSubscription = {
|
|
12
|
-
conversationId: string;
|
|
13
|
-
generation: number;
|
|
14
|
-
onEvent: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onEvent"];
|
|
15
|
-
onStatus?: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onStatus"];
|
|
16
|
-
onError?: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onError"];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export type AiLiveConnection = {
|
|
20
|
-
connect: () => void;
|
|
21
|
-
markApplied: (cursor: string | null | undefined) => void;
|
|
22
|
-
streamTransport: AiConversationStreamTransport;
|
|
23
|
-
dispose: () => void;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export type CreateAiLiveConnectionOptions = {
|
|
27
|
-
url?: string;
|
|
28
|
-
initialCursor: string;
|
|
29
|
-
onLiveMessage: (message: AiLiveServerMessage) => void;
|
|
30
|
-
onLiveStatus?: (status: LiveWebSocketStatus) => void;
|
|
31
|
-
onFatal?: (error: LiveWebSocketError) => void;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const reconnectableAiClose = (close: LiveWebSocketClose): LiveWebSocketError | null =>
|
|
35
|
-
close.code === 1008 ? { code: close.reason || "access_denied", message: "Live access changed or expired." } : null;
|
|
36
|
-
|
|
37
|
-
const streamStatus = (status: LiveWebSocketStatus): AiStreamConnectionStatus | null => {
|
|
38
|
-
if (status === "connecting" || status === "reconnecting") return status;
|
|
39
|
-
if (status === "paused") return "reconnecting";
|
|
40
|
-
return null;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/** One browser connection for AI invalidations and the currently visible turn. */
|
|
44
|
-
export const createAiLiveConnection = (options: CreateAiLiveConnectionOptions): AiLiveConnection => {
|
|
45
|
-
let socket: LiveWebSocket;
|
|
46
|
-
let turn: TurnSubscription | null = null;
|
|
47
|
-
let turnGeneration = 0;
|
|
48
|
-
let connected = false;
|
|
49
|
-
let disposed = false;
|
|
50
|
-
let liveSubscribeCount = 0;
|
|
51
|
-
|
|
52
|
-
const sendTurnSubscription = () => {
|
|
53
|
-
if (!connected || !turn) return;
|
|
54
|
-
if (
|
|
55
|
-
!socket.send({
|
|
56
|
-
type: AI_LIVE_WS_TYPE.turnSubscribe,
|
|
57
|
-
payload: { conversationId: turn.conversationId },
|
|
58
|
-
})
|
|
59
|
-
) {
|
|
60
|
-
turn.onStatus?.("reconnecting");
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
socket = createLiveWebSocket<AiLiveServerMessage>({
|
|
65
|
-
url: options.url ?? "/api/ai/live",
|
|
66
|
-
activity: "always",
|
|
67
|
-
initialCursor: options.initialCursor,
|
|
68
|
-
subscribe: (cursor) => ({
|
|
69
|
-
type: AI_LIVE_WS_TYPE.subscribe,
|
|
70
|
-
payload: { fromCursor: cursor, recover: liveSubscribeCount++ > 0 },
|
|
71
|
-
}),
|
|
72
|
-
parse: parseAiLiveServerMessage,
|
|
73
|
-
classifyClose: reconnectableAiClose,
|
|
74
|
-
onOpen: sendTurnSubscription,
|
|
75
|
-
onStatus: (status) => {
|
|
76
|
-
connected = status === "open";
|
|
77
|
-
options.onLiveStatus?.(status);
|
|
78
|
-
const next = streamStatus(status);
|
|
79
|
-
if (next) turn?.onStatus?.(next);
|
|
80
|
-
},
|
|
81
|
-
onMessage: (message) => {
|
|
82
|
-
if (message.type === AI_LIVE_WS_TYPE.turnEvent) {
|
|
83
|
-
if (!turn || turn.conversationId !== message.payload.conversationId) return;
|
|
84
|
-
if (message.payload.event.type === "state") turn.onStatus?.("open");
|
|
85
|
-
turn.onEvent(message.payload.event);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if (message.type === AI_LIVE_WS_TYPE.turnError) {
|
|
89
|
-
if (!turn || turn.conversationId !== message.payload.conversationId) return;
|
|
90
|
-
const current = turn;
|
|
91
|
-
turn = null;
|
|
92
|
-
current.onError?.(new Error(message.payload.message));
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
options.onLiveMessage(message);
|
|
96
|
-
},
|
|
97
|
-
onFatal: (error) => {
|
|
98
|
-
connected = false;
|
|
99
|
-
const current = turn;
|
|
100
|
-
turn = null;
|
|
101
|
-
current?.onError?.(new Error(error.message));
|
|
102
|
-
options.onFatal?.(error);
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const streamTransport: AiConversationStreamTransport = {
|
|
107
|
-
subscribe: ({ conversationId, onEvent, onStatus, onError }) => {
|
|
108
|
-
const generation = ++turnGeneration;
|
|
109
|
-
const subscription: TurnSubscription = { conversationId, generation, onEvent, onStatus, onError };
|
|
110
|
-
turn = subscription;
|
|
111
|
-
onStatus?.("connecting");
|
|
112
|
-
sendTurnSubscription();
|
|
113
|
-
return {
|
|
114
|
-
close: () => {
|
|
115
|
-
if (turn?.generation !== generation) return;
|
|
116
|
-
turn = null;
|
|
117
|
-
if (connected) {
|
|
118
|
-
socket.send({
|
|
119
|
-
type: AI_LIVE_WS_TYPE.turnUnsubscribe,
|
|
120
|
-
payload: { conversationId },
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
connect: () => {
|
|
130
|
-
if (!disposed) socket.connect();
|
|
131
|
-
},
|
|
132
|
-
markApplied: (cursor) => socket.markApplied(cursor),
|
|
133
|
-
streamTransport,
|
|
134
|
-
dispose: () => {
|
|
135
|
-
if (disposed) return;
|
|
136
|
-
disposed = true;
|
|
137
|
-
turn = null;
|
|
138
|
-
socket.dispose();
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
};
|