@k2b/cloud 0.38.0 → 0.39.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 +6 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +10 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +396 -66
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +28 -2
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/index.ts +2 -1
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/index.ts +21 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +42 -9
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +60 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +30 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +4 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +3 -2
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Settings store — Redis cache-aside read/write primitives.
|
|
3
3
|
*
|
|
4
|
-
* Pattern: each key is cached in Redis with a 5-minute TTL.
|
|
4
|
+
* Pattern: each key is cached in Redis with a 5-minute TTL. Value reads try Redis
|
|
5
5
|
* first, fall back to DB on miss, and populate Redis. Writes update DB and
|
|
6
6
|
* delete the Redis key (next reader repopulates with fresh DB state).
|
|
7
7
|
*
|
|
@@ -27,6 +27,19 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
|
|
|
27
27
|
|
|
28
28
|
type SqlClient = typeof sql;
|
|
29
29
|
|
|
30
|
+
export type StoredSettingState = "none" | "readable" | "unreadable";
|
|
31
|
+
export type SettingRead = { value: unknown; stored: StoredSettingState };
|
|
32
|
+
|
|
33
|
+
export class SettingUnreadableError extends HTTPException {
|
|
34
|
+
readonly code = "setting_unreadable" as const;
|
|
35
|
+
|
|
36
|
+
constructor(readonly key: string) {
|
|
37
|
+
const message = `The stored value of setting "${key}" cannot be read by this application. Replace it with a new value or reset it to the default.`;
|
|
38
|
+
super(409, { message, res: Response.json({ code: "setting_unreadable", message }, { status: 409 }) });
|
|
39
|
+
this.name = "SettingUnreadableError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/**
|
|
31
44
|
* Forget cached values so every container re-reads on next access.
|
|
32
45
|
*
|
|
@@ -70,6 +83,12 @@ const resolveFallback = (def: SettingDef | undefined): unknown => {
|
|
|
70
83
|
*/
|
|
71
84
|
export const readKey = async (key: string): Promise<unknown> => (await bulkRead([key])).get(key);
|
|
72
85
|
|
|
86
|
+
export const readKeyState = async (key: string): Promise<SettingRead> => {
|
|
87
|
+
const state = (await readStates([key])).get(key);
|
|
88
|
+
if (!state) throw new Error(`Missing read state for setting "${key}"`);
|
|
89
|
+
return state;
|
|
90
|
+
};
|
|
91
|
+
|
|
73
92
|
/** One warm MGET; missing rows are cached independently of local env/defaults. */
|
|
74
93
|
export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
|
|
75
94
|
const result = new Map<string, unknown>();
|
|
@@ -131,6 +150,32 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
|
|
|
131
150
|
return result;
|
|
132
151
|
};
|
|
133
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Stored state is per process (APP_SECRET), but the plaintext cache is shared.
|
|
155
|
+
* State reads go directly to Postgres without reading or filling the cache;
|
|
156
|
+
* value reads (`get`, snapshot) keep using the cache.
|
|
157
|
+
*/
|
|
158
|
+
export const readStates = async (keys: readonly string[]): Promise<Map<string, SettingRead>> => {
|
|
159
|
+
const result = new Map<string, SettingRead>();
|
|
160
|
+
if (keys.length === 0) return result;
|
|
161
|
+
const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray([...keys])}::text[])`;
|
|
162
|
+
const byKey = new Map(rows.map((row) => [row.key, row]));
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
const row = byKey.get(key);
|
|
165
|
+
const def = SETTINGS_MAP.get(key);
|
|
166
|
+
result.set(key, { stored: row ? "unreadable" : "none", value: resolveFallback(def) });
|
|
167
|
+
if (!row) continue;
|
|
168
|
+
try {
|
|
169
|
+
const value = await decryptValue(row.value);
|
|
170
|
+
const validated = def ? validateSettingValue(def, value) : { ok: true as const, value };
|
|
171
|
+
if (validated.ok) result.set(key, { stored: "readable", value: validated.value });
|
|
172
|
+
} catch {
|
|
173
|
+
// Keep this process's fallback without changing the stored row or cache.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
};
|
|
178
|
+
|
|
134
179
|
/**
|
|
135
180
|
* Get every known setting key (across all registered defs).
|
|
136
181
|
* Used by snapshot loader to determine what to bulk-read.
|
|
@@ -184,11 +229,12 @@ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []):
|
|
|
184
229
|
/**
|
|
185
230
|
* Encrypt the value, upsert the DB row, invalidate the Redis key.
|
|
186
231
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
232
|
+
* Validate new values and refuse to overwrite unreadable stored rows unless
|
|
233
|
+
* the caller explicitly replaces them. The row check and write share a transaction.
|
|
234
|
+
* Lock with a no-op UPDATE so ROW EXCLUSIVE is acquired before the row lock,
|
|
235
|
+
* preserving lock order with table-level SHARE locks (#1303 review).
|
|
190
236
|
*/
|
|
191
|
-
export const writeKey = async (key: string, value: unknown, db?: SqlClient): Promise<void> => {
|
|
237
|
+
export const writeKey = async (key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> => {
|
|
192
238
|
const def = SETTINGS_MAP.get(key);
|
|
193
239
|
if (!def) throw new Error(`Unknown setting: ${key}`);
|
|
194
240
|
const validated = validateSettingValue(def, value);
|
|
@@ -197,12 +243,38 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient): Pro
|
|
|
197
243
|
const storedValue =
|
|
198
244
|
def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
|
|
199
245
|
const encrypted = await encryptValue(storedValue);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
246
|
+
const write = async (tx: SqlClient) => {
|
|
247
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
248
|
+
let [row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
|
|
249
|
+
if (!row) {
|
|
250
|
+
const inserted = await tx<{ key: string }[]>`
|
|
251
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
252
|
+
VALUES (${key}, ${encrypted}, now())
|
|
253
|
+
ON CONFLICT (key) DO NOTHING RETURNING key
|
|
254
|
+
`;
|
|
255
|
+
if (inserted.length > 0) return;
|
|
256
|
+
// A concurrent insert can win after the missing-row read; lock and check its value before updating.
|
|
257
|
+
[row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
|
|
258
|
+
if (!row) continue;
|
|
259
|
+
}
|
|
260
|
+
if (options?.replaceUnreadable !== true) {
|
|
261
|
+
let readable = false;
|
|
262
|
+
try {
|
|
263
|
+
readable = validateSettingValue(def, await decryptValue(row.value)).ok;
|
|
264
|
+
} catch {
|
|
265
|
+
// Only the key is exposed by the recovery error.
|
|
266
|
+
}
|
|
267
|
+
if (!readable) throw new SettingUnreadableError(key);
|
|
268
|
+
}
|
|
269
|
+
await tx`
|
|
270
|
+
UPDATE settings.entries SET value = ${encrypted}, updated_at = now() WHERE key = ${key}
|
|
271
|
+
`;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
throw new Error(`Could not write setting "${key}" after 3 attempts`);
|
|
275
|
+
};
|
|
276
|
+
if (db) await write(db);
|
|
277
|
+
else await sql.begin(write);
|
|
206
278
|
|
|
207
279
|
// Passing a transaction means the caller owns invalidation, because the row
|
|
208
280
|
// is not visible to anyone else until they commit.
|
|
@@ -59,7 +59,7 @@ Most replies need no offer. Make at most one offer, in the last sentence of the
|
|
|
59
59
|
{%- if memoryToolEnabled %}
|
|
60
60
|
- The user corrected the tone of a result, such as a mail that is too formal, without stating a lasting rule: offer to remember it as their preference. Memory keeps only the user's own words, so a plain yes cannot be saved: suggest a one-line rule they can send back, such as "Always write mails casually and briefly".
|
|
61
61
|
{%- endif %}
|
|
62
|
-
- The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}
|
|
62
|
+
- The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}routine.
|
|
63
63
|
- A concrete next step clearly saves the user real work they may not know you can do: offer it, such as a manual follow-up the user mentioned, drafting the reply, or turning findings into Space tasks.
|
|
64
64
|
Offer only what the Skills, tool hints, and apps in this prompt support. Do not search mail, chats, or other data only to justify an offer, and do not claim how often something happened unless the user said it or this chat shows it. Do not start the offered work until the user agrees.
|
|
65
65
|
{%- if skillOffers %}
|
|
@@ -65,6 +65,7 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
|
|
|
65
65
|
return {
|
|
66
66
|
...app,
|
|
67
67
|
...resolveAppIdentityPresentation(app, requestedLocale),
|
|
68
|
+
...(app.panel ? { panel: { ...app.panel, label: translation.panelLabel ?? app.panel.label } } : {}),
|
|
68
69
|
adminNav: app.adminNav?.map((group) => ({
|
|
69
70
|
...group,
|
|
70
71
|
label: (group.id && translation.adminGroups?.[group.id]) || group.label,
|
package/src/shared/redirect.ts
CHANGED
|
@@ -17,6 +17,18 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
|
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/** Default landing page when no valid home path is configured. */
|
|
21
|
+
export const DEFAULT_HOME_PATH = "/app/dashboard";
|
|
22
|
+
|
|
23
|
+
/** Resolve a local home path, falling back for root, auth, admin, or invalid targets. */
|
|
24
|
+
export const resolveHomePath = (value: unknown): string => {
|
|
25
|
+
const path = typeof value === "string" ? normalizeRedirectTo(value) : undefined;
|
|
26
|
+
if (!path || path === "/" || path === "/auth" || path.startsWith("/auth/") || path === "/admin" || path.startsWith("/admin/")) {
|
|
27
|
+
return DEFAULT_HOME_PATH;
|
|
28
|
+
}
|
|
29
|
+
return path;
|
|
30
|
+
};
|
|
31
|
+
|
|
20
32
|
/** Extract a safe post-login redirect target from an incoming request URL. */
|
|
21
33
|
export const redirectPathFromRequestUrl = (requestUrl: string): string => {
|
|
22
34
|
const url = new URL(requestUrl);
|
|
@@ -1,30 +1,85 @@
|
|
|
1
|
-
import { useLocale } from "@k2b/ui";
|
|
2
|
-
import { createEffect } from "solid-js";
|
|
1
|
+
import { TabBar, useLocale } from "@k2b/ui";
|
|
2
|
+
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js";
|
|
3
|
+
import { isServer, Portal } from "solid-js/web";
|
|
3
4
|
import { AppLaunchpadButton, setAppLaunchpadContext } from "./app-launchpad";
|
|
4
5
|
import GlobalSearchTrigger from "./GlobalSearchTrigger";
|
|
5
6
|
import LayoutBreadcrumbs from "./LayoutBreadcrumbs";
|
|
6
7
|
import LayoutHelpTrigger from "./LayoutHelpTrigger";
|
|
7
8
|
import LayoutPreferences from "./LayoutPreferences";
|
|
8
9
|
import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
10
|
+
import PanelTrigger from "./PanelTrigger";
|
|
9
11
|
import ProfilePreferences from "./ProfilePreferences";
|
|
10
12
|
import { platformMessages } from "./platform-messages";
|
|
11
13
|
import RailApps from "./RailApps";
|
|
14
|
+
import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
|
|
15
|
+
import { phoneBarItems, projectRailNavigation } from "./rail-navigation";
|
|
12
16
|
import ShellFocusToggle from "./ShellFocusToggle";
|
|
13
17
|
import { useShellData } from "./shell-data";
|
|
14
18
|
|
|
15
19
|
type CloudShellProps =
|
|
16
|
-
| { slot: "rail"; currentPath: string }
|
|
17
|
-
| { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] };
|
|
20
|
+
| { slot: "rail"; currentPath: string; panelOpen?: boolean }
|
|
21
|
+
| { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[]; panelOpen?: boolean };
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* The interactive part of the shell chrome. `LayoutRail` and `LayoutHeader`
|
|
21
25
|
* own the frame and the plain links around it as server markup.
|
|
22
26
|
*/
|
|
23
27
|
export default function CloudShell(props: CloudShellProps) {
|
|
24
|
-
return props.slot === "rail" ?
|
|
28
|
+
return props.slot === "rail" ? (
|
|
29
|
+
<RailNavigation currentPath={props.currentPath} panelOpen={props.panelOpen} />
|
|
30
|
+
) : (
|
|
31
|
+
<HeaderControls {...props} />
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The phone bar's live copy, mounted with the rail's island so the shell adds no island instance. It renders nothing
|
|
37
|
+
* while the server's bar is current, so mounting moves and refocuses nothing. When the saved rail preferences or the
|
|
38
|
+
* URL (a fragment, Back and Forward, an in-page navigation) change what the bar shows, it renders the new bar into the
|
|
39
|
+
* bar's row, and CSS hides the server's.
|
|
40
|
+
*/
|
|
41
|
+
function BarNavigation(props: { currentPath: string }) {
|
|
42
|
+
if (isServer) return null;
|
|
43
|
+
const data = useShellData();
|
|
44
|
+
const locale = useLocale();
|
|
45
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
46
|
+
const initial = data?.rail;
|
|
47
|
+
if (!data || !initial) return null;
|
|
48
|
+
const [settings, setSettings] = createSignal(initial);
|
|
49
|
+
const [currentPath, setCurrentPath] = createSignal(props.currentPath);
|
|
50
|
+
const itemsFor = (rail: typeof initial, path: string) =>
|
|
51
|
+
phoneBarItems(projectRailNavigation(data.apps, rail, locale()), path, { label: t().home, path: data.home ?? "/" });
|
|
52
|
+
const served = JSON.stringify(itemsFor(initial, props.currentPath));
|
|
53
|
+
const items = createMemo(() => itemsFor(settings(), currentPath()));
|
|
54
|
+
onMount(() => {
|
|
55
|
+
const sync = () => setSettings(readRailContext()?.settings ?? initial);
|
|
56
|
+
const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
|
|
57
|
+
syncLocation();
|
|
58
|
+
window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
59
|
+
window.addEventListener("popstate", syncLocation);
|
|
60
|
+
window.addEventListener("hashchange", syncLocation);
|
|
61
|
+
// In-page navigation (`@k2b/ssr/nav`) commits with pushState or replaceState, which only the Navigation API
|
|
62
|
+
// reports; where a browser lacks it, the bar follows at the next page load.
|
|
63
|
+
window.navigation?.addEventListener("currententrychange", syncLocation);
|
|
64
|
+
onCleanup(() => {
|
|
65
|
+
window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
66
|
+
window.removeEventListener("popstate", syncLocation);
|
|
67
|
+
window.removeEventListener("hashchange", syncLocation);
|
|
68
|
+
window.navigation?.removeEventListener("currententrychange", syncLocation);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
const row = document.querySelector<HTMLElement>(".cloud-tab-bar__row");
|
|
72
|
+
if (!row) return null;
|
|
73
|
+
return (
|
|
74
|
+
<Show when={JSON.stringify(items()) !== served}>
|
|
75
|
+
<Portal mount={row}>
|
|
76
|
+
<TabBar variant="floating" class="cloud-tab-bar__live" label={t().apps} items={items()} />
|
|
77
|
+
</Portal>
|
|
78
|
+
</Show>
|
|
79
|
+
);
|
|
25
80
|
}
|
|
26
81
|
|
|
27
|
-
function RailNavigation(props: { currentPath: string }) {
|
|
82
|
+
function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
|
|
28
83
|
const data = useShellData();
|
|
29
84
|
const locale = useLocale();
|
|
30
85
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
@@ -36,7 +91,9 @@ function RailNavigation(props: { currentPath: string }) {
|
|
|
36
91
|
<ShellFocusToggle variant="rail" />
|
|
37
92
|
<RailApps apps={data.apps} settings={data.rail} currentPath={props.currentPath} />
|
|
38
93
|
<AppLaunchpadButton profile={profile} apps={data.apps} legalLinks={data.legalLinks} variant="rail" label={t().openApps} />
|
|
94
|
+
<BarNavigation currentPath={props.currentPath} />
|
|
39
95
|
<div class="mt-auto shrink-0 flex flex-col items-center gap-1">
|
|
96
|
+
{(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="rail" open={props.panelOpen} />}
|
|
40
97
|
<GlobalSearchTrigger variant="rail" />
|
|
41
98
|
<LayoutHelpTrigger variant="rail" />
|
|
42
99
|
<ProfilePreferences
|
|
@@ -51,7 +108,13 @@ function RailNavigation(props: { currentPath: string }) {
|
|
|
51
108
|
}
|
|
52
109
|
|
|
53
110
|
/** The header's title and controls, between the server-rendered Home and Sign in. */
|
|
54
|
-
function HeaderControls(props: {
|
|
111
|
+
function HeaderControls(props: {
|
|
112
|
+
authenticated: boolean;
|
|
113
|
+
title?: string;
|
|
114
|
+
launchpad: boolean;
|
|
115
|
+
breadcrumbs?: LayoutBreadcrumb[];
|
|
116
|
+
panelOpen?: boolean;
|
|
117
|
+
}) {
|
|
55
118
|
const data = useShellData();
|
|
56
119
|
const locale = useLocale();
|
|
57
120
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
@@ -65,13 +128,27 @@ function HeaderControls(props: { authenticated: boolean; title?: string; launchp
|
|
|
65
128
|
</div>
|
|
66
129
|
)}
|
|
67
130
|
<div class="flex min-w-0 items-center lg:hidden">
|
|
68
|
-
<span class="truncate font-semibold">{props.title}</span>
|
|
131
|
+
<span class="layout-header-title truncate font-semibold">{props.title}</span>
|
|
132
|
+
{props.launchpad && (
|
|
133
|
+
<AppLaunchpadButton
|
|
134
|
+
profile={
|
|
135
|
+
data.profile
|
|
136
|
+
? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) }
|
|
137
|
+
: undefined
|
|
138
|
+
}
|
|
139
|
+
apps={data.apps}
|
|
140
|
+
legalLinks={data.legalLinks}
|
|
141
|
+
variant="title"
|
|
142
|
+
label={props.title}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
69
145
|
</div>
|
|
70
146
|
</div>
|
|
71
147
|
<div class="flex shrink-0 items-center gap-1">
|
|
72
148
|
<div class="flex items-center gap-1 lg:hidden">
|
|
73
|
-
<
|
|
149
|
+
{(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="header" open={props.panelOpen} />}
|
|
74
150
|
{props.authenticated && <GlobalSearchTrigger variant="header" />}
|
|
151
|
+
<LayoutHelpTrigger variant="header" />
|
|
75
152
|
{props.launchpad && (
|
|
76
153
|
<AppLaunchpadButton
|
|
77
154
|
profile={
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import type { DateContext } from "@k2b/stdlib";
|
|
|
2
2
|
import { AppWorkspace, appWorkspaceLayoutStyle, keyboardPlatform, 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
|
+
import { readPanelCookie } from "../_internal/panel-cookie";
|
|
5
6
|
import { readShellFocusCookie } from "../_internal/shell-focus";
|
|
6
7
|
import { resolveNavMatch } from "../contracts/app"; // ==========================
|
|
7
8
|
import { isPwaShellAvailable } from "../contracts/pwa";
|
|
@@ -9,19 +10,21 @@ import { defaultRailPreferences } from "../contracts/rail-preferences";
|
|
|
9
10
|
import { hasRole, type User } from "../contracts/shared";
|
|
10
11
|
import { getLocale } from "../server/locale";
|
|
11
12
|
import { getDateConfig } from "../server/time";
|
|
12
|
-
import { dates } from "../shared";
|
|
13
|
+
import { dates, resolveHomePath } from "../shared";
|
|
13
14
|
import { readThemePreferenceFromCookieHeader } from "../shared/theme";
|
|
14
15
|
import AnnouncementBanners from "./AnnouncementBanners";
|
|
15
16
|
import { appAppearanceStyle, resolveCurrentApp } from "./app-appearance";
|
|
16
|
-
import { resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
|
|
17
|
+
import { resolveRuntimeRoute, visibleNavigationApps, visiblePanelProviders } from "./app-navigation";
|
|
17
18
|
import { getLayoutHelp, layoutHelpFailed } from "./help";
|
|
18
19
|
import LayoutFooter from "./LayoutFooter";
|
|
19
20
|
import LayoutHeader from "./LayoutHeader";
|
|
20
21
|
import LayoutRail, { type LayoutAppLink } from "./LayoutRail";
|
|
22
|
+
import LayoutTabBar from "./LayoutTabBar";
|
|
21
23
|
import type { LayoutContext } from "./layout-context";
|
|
22
24
|
import { HELP_VIEW_LIMIT } from "./layout-help-start";
|
|
23
25
|
import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
24
26
|
import PageTrailRow from "./PageTrailRow";
|
|
27
|
+
import PanelSlot from "./PanelSlot";
|
|
25
28
|
import { derivePageTrail } from "./page-trail";
|
|
26
29
|
import { platformMessages } from "./platform-messages";
|
|
27
30
|
import { sortRailApps } from "./rail-navigation";
|
|
@@ -41,6 +44,8 @@ type LayoutProps = {
|
|
|
41
44
|
help?: readonly string[];
|
|
42
45
|
fullPage?: boolean /** Keep the shell viewport-bound and suppress its footer. */;
|
|
43
46
|
fullWidth?: boolean /** Delegate scrolling and clipping to the page's work surface. */;
|
|
47
|
+
/** Hide the panel slot on this page (sign-in, print, presentation, public pages). */
|
|
48
|
+
panel?: false;
|
|
44
49
|
focusMode?: boolean /** Render only the app canvas and content for dedicated editors or pop-out windows. */;
|
|
45
50
|
flushCanvas?: boolean /** Remove app-canvas spacing for edge-to-edge focus surfaces such as pop-out windows. */;
|
|
46
51
|
/** Final localized app-specific export or printing guidance, shown when no data-print-region exists. */
|
|
@@ -155,6 +160,12 @@ export default function Layout(props: LayoutProps) {
|
|
|
155
160
|
const lang = getLocale(c);
|
|
156
161
|
const t = platformMessages.resolve([lang]).t;
|
|
157
162
|
const user = c.get("user");
|
|
163
|
+
const providers = user && props.panel !== false ? visiblePanelProviders(runtime.apps, user, lang) : [];
|
|
164
|
+
const panelState = readPanelCookie(
|
|
165
|
+
providers.length ? cookie : undefined,
|
|
166
|
+
providers.map((provider) => provider.id),
|
|
167
|
+
);
|
|
168
|
+
const panel = providers.length ? { providers, provider: panelState.provider ?? providers[0]?.id } : undefined;
|
|
158
169
|
const pathname = new URL(c.req.raw.url).pathname;
|
|
159
170
|
const currentRoute = resolveRuntimeRoute(runtime.apps, pathname);
|
|
160
171
|
const currentApp = resolveCurrentApp(runtime.apps, pathname);
|
|
@@ -207,19 +218,29 @@ export default function Layout(props: LayoutProps) {
|
|
|
207
218
|
// keeps short and long pages at the same width where scrollbars take space.
|
|
208
219
|
const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "md:overflow-auto md:[scrollbar-gutter:stable]";
|
|
209
220
|
const canvasStyle =
|
|
210
|
-
[
|
|
221
|
+
[
|
|
222
|
+
appAppearanceStyle(currentApp?.appearance),
|
|
223
|
+
appWorkspaceLayoutStyle(workspaceLayout),
|
|
224
|
+
panelState.widthRem !== undefined ? `--cloud-panel-width-set:${panelState.widthRem}rem` : "",
|
|
225
|
+
focusMode && flushCanvas ? "padding:0" : "",
|
|
226
|
+
]
|
|
211
227
|
.filter(Boolean)
|
|
212
228
|
.join(";") || undefined;
|
|
213
229
|
const appLabel = currentApp?.name ?? (pathname.startsWith("/admin") ? t.admin : undefined);
|
|
214
230
|
const helpIds = new Set(registeredHelp?.documents.map((topic) => topic.id));
|
|
215
231
|
const viewHelp = [...new Set(props.help)].filter((id) => helpIds.has(id)).slice(0, HELP_VIEW_LIMIT);
|
|
216
232
|
const shellData: ShellData = {
|
|
233
|
+
...(panel ? { panel } : {}),
|
|
217
234
|
platform: keyboardPlatform(c.req.raw.headers.get("User-Agent") ?? ""),
|
|
218
235
|
...(viewHelp.length ? { help: viewHelp } : {}),
|
|
219
236
|
...(layoutHelpFailed(c) ? { helpFailed: true as const } : {}),
|
|
220
237
|
apps: allApps,
|
|
221
238
|
...(user
|
|
222
|
-
? {
|
|
239
|
+
? {
|
|
240
|
+
rail: c.get("railPreferences") ?? defaultRailPreferences(),
|
|
241
|
+
home: resolveHomePath(settings?.app?.home_path),
|
|
242
|
+
profile: { name: profileName, avatarSrc: profileAvatarSrc, appHref },
|
|
243
|
+
}
|
|
223
244
|
: {}),
|
|
224
245
|
legalLinks,
|
|
225
246
|
theme,
|
|
@@ -231,20 +252,27 @@ export default function Layout(props: LayoutProps) {
|
|
|
231
252
|
return (
|
|
232
253
|
<LocaleProvider locale={lang}>
|
|
233
254
|
<div
|
|
234
|
-
class="cloud-app-canvas flex h-dvh w-full overflow-hidden"
|
|
255
|
+
class="cloud-app-canvas relative flex h-dvh w-full overflow-hidden"
|
|
235
256
|
style={canvasStyle}
|
|
257
|
+
data-panel-transient=""
|
|
236
258
|
data-app-id={currentApp?.id}
|
|
237
259
|
data-app-base={currentRoute?.prefix}
|
|
238
260
|
data-page-trail-title-only=""
|
|
239
261
|
data-layout-full-page="true"
|
|
240
262
|
data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
|
|
241
263
|
>
|
|
242
|
-
<ShellRuntime pageTrail help={runtimeHelp} />
|
|
264
|
+
<ShellRuntime pageTrail help={runtimeHelp} panel={panel ? true : undefined} />
|
|
243
265
|
<PageTrailProvider trail={pageTrail}>
|
|
244
|
-
<main class="min-h-0 min-w-0 flex-1">
|
|
266
|
+
<main class="min-h-0 min-w-0 flex-1" data-cloud-app-area="">
|
|
245
267
|
<AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
|
|
246
268
|
</main>
|
|
247
269
|
</PageTrailProvider>
|
|
270
|
+
{/* Focus pages carry shell data only for their panel providers. */}
|
|
271
|
+
{panel && (
|
|
272
|
+
<ShellDataProvider data={{ panel, platform: shellData.platform, theme, apps: [], legalLinks: [], searchLinks: {} }}>
|
|
273
|
+
<PanelSlot panel={panel} state={{ ...panelState, open: false }} platform={shellData.platform} />
|
|
274
|
+
</ShellDataProvider>
|
|
275
|
+
)}
|
|
248
276
|
<div data-print-fallback="">
|
|
249
277
|
<p>{t.printFallbackTitle}</p>
|
|
250
278
|
<p>{props.printHint ?? t.printFallbackHint}</p>
|
|
@@ -261,6 +289,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
261
289
|
<div
|
|
262
290
|
class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
|
|
263
291
|
style={canvasStyle}
|
|
292
|
+
data-panel={panelState.open ? "open" : undefined}
|
|
264
293
|
data-app-id={currentApp?.id}
|
|
265
294
|
data-app-base={currentRoute?.prefix}
|
|
266
295
|
data-page-trail-title-only={titleOnly ? "" : undefined}
|
|
@@ -270,6 +299,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
270
299
|
data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
|
|
271
300
|
>
|
|
272
301
|
<ShellRuntime
|
|
302
|
+
panel={panel ? true : undefined}
|
|
273
303
|
workspace={{ appId: currentApp?.id }}
|
|
274
304
|
pageTrail
|
|
275
305
|
focus={showRail}
|
|
@@ -284,11 +314,12 @@ export default function Layout(props: LayoutProps) {
|
|
|
284
314
|
banners={user && announcements?.banners.length ? true : undefined}
|
|
285
315
|
/>
|
|
286
316
|
<ShellDataProvider data={shellData}>
|
|
287
|
-
{showRail && <LayoutRail currentPath={requestPath(c)} />}
|
|
317
|
+
{showRail && <LayoutRail currentPath={requestPath(c)} panelOpen={panel ? panelState.open : undefined} />}
|
|
288
318
|
{showRail && <ShellFocusToggle variant="float" />}
|
|
289
319
|
{showRail && <div class="cloud-shell-reveal" data-cloud-shell-reveal="" aria-hidden="true" />}
|
|
290
|
-
<div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
|
|
320
|
+
<div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col" data-cloud-app-area="">
|
|
291
321
|
<LayoutHeader
|
|
322
|
+
panelOpen={panel ? panelState.open : undefined}
|
|
292
323
|
authenticated={showRail}
|
|
293
324
|
title={appLabel ?? breadcrumbs.at(-1)?.title}
|
|
294
325
|
launchpad={!!(user || appLabel)}
|
|
@@ -305,6 +336,8 @@ export default function Layout(props: LayoutProps) {
|
|
|
305
336
|
</PageTrailProvider>
|
|
306
337
|
{!fullPage && !showRail && <LayoutFooter appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />}
|
|
307
338
|
</div>
|
|
339
|
+
{panel && <PanelSlot panel={panel} state={panelState} platform={shellData.platform} />}
|
|
340
|
+
{showRail && <LayoutTabBar currentPath={requestPath(c)} />}
|
|
308
341
|
</ShellDataProvider>
|
|
309
342
|
<div data-print-fallback="">
|
|
310
343
|
<p>{t.printFallbackTitle}</p>
|
package/src/ssr/LayoutHeader.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { platformMessages } from "./platform-messages";
|
|
|
5
5
|
|
|
6
6
|
type LayoutHeaderProps = {
|
|
7
7
|
authenticated: boolean;
|
|
8
|
+
panelOpen?: boolean;
|
|
8
9
|
title?: string;
|
|
9
10
|
launchpad: boolean;
|
|
10
11
|
breadcrumbs?: LayoutBreadcrumb[];
|
|
@@ -14,10 +15,12 @@ type LayoutHeaderProps = {
|
|
|
14
15
|
* Phones (below `md`) always get this header. From `md` it remains only on
|
|
15
16
|
* anonymous pages; signed-in people reach Home, search, help, the app grid,
|
|
16
17
|
* and their profile through the rail, so the app area starts at the top edge.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* On a signed-in phone it is the title row on the app gradient: Home moves to
|
|
19
|
+
* the bottom bar's Start, so the row holds the title, search, help, and the
|
|
20
|
+
* app menu. Anonymous pages have no rail: up to `lg` their header keeps Home,
|
|
21
|
+
* the app name, help, search, and the app grid, and from `lg` it shows the
|
|
22
|
+
* breadcrumbs. Home and Sign in stay server markup, so focus on them survives
|
|
23
|
+
* the shell's mount; the shell renders the title and the controls between them.
|
|
21
24
|
*/
|
|
22
25
|
export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
23
26
|
const locale = useLocale();
|
|
@@ -29,13 +32,16 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
|
29
32
|
>
|
|
30
33
|
{/* A long title must not squeeze the button; `shrink-0` sits on a wrapper because the button's own
|
|
31
34
|
styles would win over a utility on the link. */}
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
{!props.authenticated && (
|
|
36
|
+
<div class="flex shrink-0">
|
|
37
|
+
<IconButtonLink href="/" label={t().home}>
|
|
38
|
+
<img src="/branding/logo" alt="" class="h-6 w-6" />
|
|
39
|
+
</IconButtonLink>
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
37
42
|
<CloudShell
|
|
38
43
|
slot="header"
|
|
44
|
+
panelOpen={props.panelOpen}
|
|
39
45
|
authenticated={props.authenticated}
|
|
40
46
|
title={props.title}
|
|
41
47
|
launchpad={props.launchpad}
|
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -101,7 +101,7 @@ const StartShortcuts = (props: { appName?: string }) => {
|
|
|
101
101
|
const locale = useLocale();
|
|
102
102
|
const t = () => helpMessages.resolve([locale()]).t;
|
|
103
103
|
const shell = useShellData();
|
|
104
|
-
const seed: HelpShortcutCommand[] = shell ? shellShortcuts(platformMessages.resolve([locale()]).t) : [];
|
|
104
|
+
const seed: HelpShortcutCommand[] = shell ? shellShortcuts(platformMessages.resolve([locale()]).t, { panel: !!shell.panel }) : [];
|
|
105
105
|
const read = (): HelpShortcutCommand[] => {
|
|
106
106
|
const commands = contextCommandsWithShortcuts();
|
|
107
107
|
return [...seed.filter((command) => !commands.some((other) => other.id === command.id)), ...commands];
|
package/src/ssr/LayoutRail.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import type { RailApp } from "./rail-navigation";
|
|
|
5
5
|
export type LayoutAppLink = RailApp;
|
|
6
6
|
|
|
7
7
|
/** The rail frame and Home stay server markup; only the navigation is interactive. */
|
|
8
|
-
export default function LayoutRail(props: { currentPath: string }) {
|
|
8
|
+
export default function LayoutRail(props: { currentPath: string; panelOpen?: boolean }) {
|
|
9
9
|
const locale = useLocale();
|
|
10
10
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
11
11
|
return (
|
|
@@ -15,7 +15,7 @@ export default function LayoutRail(props: { currentPath: string }) {
|
|
|
15
15
|
<img src="/branding/logo" alt="Logo" class="h-5 w-5" />
|
|
16
16
|
</a>
|
|
17
17
|
</div>
|
|
18
|
-
<CloudShell slot="rail" currentPath={props.currentPath} />
|
|
18
|
+
<CloudShell slot="rail" currentPath={props.currentPath} panelOpen={props.panelOpen} />
|
|
19
19
|
</aside>
|
|
20
20
|
);
|
|
21
21
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TabBar, useLocale } from "@k2b/ui";
|
|
2
|
+
import { platformMessages } from "./platform-messages";
|
|
3
|
+
import { phoneBarItems, projectRailNavigation } from "./rail-navigation";
|
|
4
|
+
import { useShellData } from "./shell-data";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The signed-in phone's bottom bar: Home and the person's first apps in their rail order, floating on the app
|
|
8
|
+
* gradient above the content card. It is server markup, so it stands in its final place before any island mounts and
|
|
9
|
+
* a link focused early keeps its focus. The rail's island takes over only when the bar's content changes in the
|
|
10
|
+
* browser: after the person saves their rail preferences, or when the URL changes in the page. The
|
|
11
|
+
* place of the app's primary action beside it stays free. From 768 px the rail takes over and the bar is hidden.
|
|
12
|
+
*/
|
|
13
|
+
export default function LayoutTabBar(props: { currentPath: string }) {
|
|
14
|
+
const data = useShellData();
|
|
15
|
+
const locale = useLocale();
|
|
16
|
+
if (!data?.rail) return null;
|
|
17
|
+
const t = platformMessages.resolve([locale()]).t;
|
|
18
|
+
const items = phoneBarItems(projectRailNavigation(data.apps, data.rail, locale()), props.currentPath, {
|
|
19
|
+
label: t.home,
|
|
20
|
+
path: data.home ?? "/",
|
|
21
|
+
});
|
|
22
|
+
return (
|
|
23
|
+
<div class="cloud-tab-bar">
|
|
24
|
+
<div class="cloud-tab-bar__row">
|
|
25
|
+
<TabBar variant="floating" label={t.apps} items={items} />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { LocaleProvider } from "@k2b/ui";
|
|
2
|
+
import type { JSX } from "solid-js";
|
|
3
|
+
import { canonicalRoute } from "../_internal/app-path";
|
|
4
|
+
import { getLocale } from "../server/locale";
|
|
5
|
+
import { resolveRuntimeRoute } from "./app-navigation";
|
|
6
|
+
import type { MinimalLayoutContext } from "./layout-context";
|
|
7
|
+
import PanelRuntime from "./PanelRuntime.island";
|
|
8
|
+
import { getRuntimeContext } from "./runtime";
|
|
9
|
+
|
|
10
|
+
export type PanelLayoutProps = { c: MinimalLayoutContext; title: string; children: JSX.Element };
|
|
11
|
+
|
|
12
|
+
/** A provider page embedded in the shell's same-origin panel. */
|
|
13
|
+
export default function PanelLayout(props: PanelLayoutProps) {
|
|
14
|
+
const runtime = getRuntimeContext(props.c);
|
|
15
|
+
// The provider owns this path by the canonical prefixes its panel declaration was checked against.
|
|
16
|
+
const apps = runtime.apps.map((entry) => ({ ...entry, routes: entry.routes.map(canonicalRoute) }));
|
|
17
|
+
const app = resolveRuntimeRoute(apps, new URL(props.c.req.raw.url).pathname)?.app;
|
|
18
|
+
props.c.get("page").title = props.title;
|
|
19
|
+
return (
|
|
20
|
+
<LocaleProvider locale={getLocale(props.c)}>
|
|
21
|
+
<div class="cloud-panel-page" data-cloud-panel-page="" data-panel-provider={app?.id}>
|
|
22
|
+
<PanelRuntime provider={app?.id} scope={app?.panel ? new URL(app.panel.href, props.c.req.raw.url).pathname : undefined} />
|
|
23
|
+
{props.children}
|
|
24
|
+
</div>
|
|
25
|
+
</LocaleProvider>
|
|
26
|
+
);
|
|
27
|
+
}
|