@k2b/cloud 0.18.0 → 0.20.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 +13 -13
- package/scripts/build.ts +3 -1
- package/scripts/sync-recovery-smoke.ts +1 -4
- package/src/_internal/define-app.ts +6 -1
- package/src/_internal/process-sync.ts +16 -8
- package/src/_internal/status-preserving-ssr.ts +9 -1
- package/src/_internal/sync-budget.ts +306 -0
- package/src/ai/code-mode-skill.ts +3 -3
- package/src/ai/html-pdf-tool.ts +2 -1
- package/src/ai/markdown-pdf-tool.ts +9 -1
- package/src/config/env.ts +5 -0
- package/src/server/locale.ts +17 -4
- package/src/server/middleware/validator.ts +23 -1
- package/src/services/gateway.ts +5 -0
- package/src/services/index.ts +2 -0
- package/src/services/pdf/gotenberg.ts +9 -2
- package/src/services/pdf/index.ts +2 -0
- package/src/services/pdf/markdown.ts +53 -13
- package/src/services/pdf/offline-html.ts +55 -7
- package/src/services/pdf/template-preview.ts +3 -1
- package/src/services/settings/core-settings.ts +2 -0
- package/src/shared/markdown/index.ts +15 -4
- package/src/ssr/LayoutHeader.tsx +20 -42
- package/src/ssr/LayoutPreferences.island.tsx +2 -17
- package/src/ssr/MinimalLayout.tsx +44 -20
- package/src/ssr/MinimalLayoutPreferences.island.tsx +48 -0
- package/src/ssr/PageError.tsx +1 -1
- package/src/ssr/profile-preferences-messages.ts +4 -0
- package/src/ssr/workspace-navigation.ts +3 -1
- package/src/styles/utilities-navigation.css +69 -20
|
@@ -1,46 +1,70 @@
|
|
|
1
1
|
import { LocaleProvider } from "@k2b/ui";
|
|
2
2
|
import type { JSX } from "solid-js/jsx-runtime";
|
|
3
3
|
import { getLocale } from "../server/locale";
|
|
4
|
+
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
4
5
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
5
|
-
import LayoutPreferences from "./LayoutPreferences.island";
|
|
6
6
|
import type { MinimalLayoutContext } from "./layout-context";
|
|
7
|
+
import MinimalLayoutPreferences from "./MinimalLayoutPreferences.island";
|
|
8
|
+
import { profilePreferencesMessages } from "./profile-preferences-messages";
|
|
7
9
|
import TimezoneCookie from "./TimezoneCookie.island";
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @deprecated The language and theme settings always sit in the footer; a
|
|
13
|
+
* position is treated like `true`.
|
|
14
|
+
*/
|
|
9
15
|
export type MinimalLayoutPreferencePosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
10
16
|
|
|
11
17
|
export type MinimalLayoutProps = {
|
|
12
18
|
children: JSX.Element;
|
|
13
19
|
c: MinimalLayoutContext;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (position === "top-left") return "bottom-right" as const;
|
|
20
|
-
if (position === "top-right") return "bottom-left" as const;
|
|
21
|
-
if (position === "bottom-left") return "top-right" as const;
|
|
22
|
-
return "top-left" as const;
|
|
20
|
+
/**
|
|
21
|
+
* Render the footer with legal links and the language and theme settings
|
|
22
|
+
* (default), or `false` for embeds and fixed presentation surfaces.
|
|
23
|
+
*/
|
|
24
|
+
preferences?: boolean | MinimalLayoutPreferencePosition;
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
export default function MinimalLayout(props: MinimalLayoutProps) {
|
|
26
28
|
const cookie = props.c.req.raw.headers.get("Cookie") ?? "";
|
|
27
29
|
const theme = readThemeFromCookieHeader(cookie);
|
|
28
30
|
const locale = getLocale(props.c);
|
|
29
|
-
const preferences = props.preferences === undefined ? "bottom-right" : props.preferences;
|
|
30
31
|
props.c.get("page").theme = theme;
|
|
31
32
|
|
|
33
|
+
if (props.preferences === false) {
|
|
34
|
+
return (
|
|
35
|
+
<LocaleProvider locale={locale}>
|
|
36
|
+
<TimezoneCookie />
|
|
37
|
+
{props.children}
|
|
38
|
+
</LocaleProvider>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const t = profilePreferencesMessages.resolve([locale]).t;
|
|
43
|
+
// Aggregate every running app's legal links like Layout (last wins on
|
|
44
|
+
// duplicate href). Unlike Layout, MinimalLayout does not require
|
|
45
|
+
// middleware.runtime(); without it the footer has no legal links.
|
|
46
|
+
const apps = resolveAppPresentations(props.c.get("runtime")?.apps ?? [], locale);
|
|
47
|
+
const legalLinks = [...new Map(apps.flatMap((app) => (app.legalLinks ?? []).map((link) => [link.href, link] as const))).values()];
|
|
48
|
+
|
|
32
49
|
return (
|
|
33
50
|
<LocaleProvider locale={locale}>
|
|
34
51
|
<TimezoneCookie />
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
<div class="minimal-layout">
|
|
53
|
+
{props.children}
|
|
54
|
+
<footer class="minimal-layout-footer">
|
|
55
|
+
{legalLinks.length > 0 && (
|
|
56
|
+
<nav class="minimal-layout-footer__links" aria-label={t.legalLinks}>
|
|
57
|
+
{legalLinks.map((link) => (
|
|
58
|
+
// A new tab keeps a started upload or a filled-in form on this page.
|
|
59
|
+
<a href={link.href} target="_blank" rel="noopener">
|
|
60
|
+
{link.label}
|
|
61
|
+
</a>
|
|
62
|
+
))}
|
|
63
|
+
</nav>
|
|
64
|
+
)}
|
|
65
|
+
<MinimalLayoutPreferences initialTheme={theme} />
|
|
66
|
+
</footer>
|
|
67
|
+
</div>
|
|
44
68
|
</LocaleProvider>
|
|
45
69
|
);
|
|
46
70
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Button, Dropdown, useLocale } from "@k2b/ui";
|
|
2
|
+
import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
|
|
3
|
+
import { canonicalLocale } from "../shared/locale";
|
|
4
|
+
import type { CloudTheme } from "../shared/theme";
|
|
5
|
+
import { createPreferenceController } from "./preference-controller";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Labeled language and theme controls for the MinimalLayout footer. The
|
|
9
|
+
* language trigger names the current language; the theme button names the
|
|
10
|
+
* mode it switches to, like every other Cloud preference menu.
|
|
11
|
+
*/
|
|
12
|
+
export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
|
|
13
|
+
const locale = useLocale();
|
|
14
|
+
const preferences = createPreferenceController(props.initialTheme, locale);
|
|
15
|
+
const t = preferences.messages;
|
|
16
|
+
const language = () => profilePreferenceLocale(locale());
|
|
17
|
+
const languageName = () => (language() === "de" ? t().switchToGerman : t().switchToEnglish);
|
|
18
|
+
// Keep a regional locale such as en-GB when its language is chosen again,
|
|
19
|
+
// but let a visitor on an unsupported locale such as fr-FR, who reads the
|
|
20
|
+
// English fallback, still choose English explicitly.
|
|
21
|
+
const choose = (next: ProfilePreferenceLocale) => {
|
|
22
|
+
if (canonicalLocale(locale())?.split("-")[0] !== next) setLocalePreference(next);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div class="minimal-layout-footer__preferences">
|
|
27
|
+
<Dropdown.Root
|
|
28
|
+
label={t().language}
|
|
29
|
+
position="top-right"
|
|
30
|
+
width="10rem"
|
|
31
|
+
items={[
|
|
32
|
+
{ label: t().switchToGerman, choice: "radio", checked: () => language() === "de", action: () => choose("de") },
|
|
33
|
+
{ label: t().switchToEnglish, choice: "radio", checked: () => language() === "en", action: () => choose("en") },
|
|
34
|
+
]}
|
|
35
|
+
>
|
|
36
|
+
<Dropdown.Trigger label={`${t().language}: ${languageName()}`} size="sm" tooltip={false} variant="ghost">
|
|
37
|
+
<i class="ti ti-language" aria-hidden="true" />
|
|
38
|
+
{languageName()}
|
|
39
|
+
<i class="ti ti-chevron-down" aria-hidden="true" />
|
|
40
|
+
</Dropdown.Trigger>
|
|
41
|
+
</Dropdown.Root>
|
|
42
|
+
<Button onClick={preferences.toggleTheme} size="sm" variant="ghost">
|
|
43
|
+
<i class={preferences.theme() === "light" ? "ti ti-moon" : "ti ti-sun-high"} aria-hidden="true" />
|
|
44
|
+
{preferences.themeLabel()}
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
package/src/ssr/PageError.tsx
CHANGED
|
@@ -17,7 +17,7 @@ export const renderPageError = (c: Context, status: PageErrorStatus, options: Pa
|
|
|
17
17
|
return () =>
|
|
18
18
|
options.layout === "minimal" ? (
|
|
19
19
|
<MinimalLayout c={c}>
|
|
20
|
-
<main class="
|
|
20
|
+
<main class="flex-1 bg-[var(--ui-canvas)] p-[var(--ui-space-shell)]">
|
|
21
21
|
<NotFoundState code={String(status)} title={title} description={description} action={action} />
|
|
22
22
|
</main>
|
|
23
23
|
</MinimalLayout>
|
|
@@ -6,6 +6,8 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
6
6
|
en: {
|
|
7
7
|
menuLabel: "Profile and preferences",
|
|
8
8
|
preferencesMenuLabel: "Appearance and language",
|
|
9
|
+
language: "Language",
|
|
10
|
+
legalLinks: "Legal",
|
|
9
11
|
profileSettings: "Profile settings",
|
|
10
12
|
signOut: "Sign out",
|
|
11
13
|
signOutFailed: "Sign out failed. Please try again.",
|
|
@@ -17,6 +19,8 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
17
19
|
de: {
|
|
18
20
|
menuLabel: "Profil und Einstellungen",
|
|
19
21
|
preferencesMenuLabel: "Darstellung und Sprache",
|
|
22
|
+
language: "Sprache",
|
|
23
|
+
legalLinks: "Rechtliches",
|
|
20
24
|
profileSettings: "Profileinstellungen",
|
|
21
25
|
signOut: "Abmelden",
|
|
22
26
|
signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
|
|
@@ -49,12 +49,14 @@ export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined
|
|
|
49
49
|
try {
|
|
50
50
|
const value: unknown = JSON.parse(owner.textContent ?? "");
|
|
51
51
|
if (!value || typeof value !== "object" || !("items" in value) || !validItems(value.items)) return;
|
|
52
|
-
// SSR links work before the app island loads; action handlers are not yet available
|
|
52
|
+
// SSR links work before the app island loads; action and disclosure handlers are not yet available,
|
|
53
|
+
// so the owner's folds only set where the local disclosure starts.
|
|
53
54
|
const links = (items: readonly NavigationItem[]): NavigationItem[] =>
|
|
54
55
|
items.map((item) => ({
|
|
55
56
|
...item,
|
|
56
57
|
disabled: item.disabled || Boolean(item.action && !item.href),
|
|
57
58
|
...(item.href ? { action: undefined } : {}),
|
|
59
|
+
...(item.expanded !== undefined ? { expanded: undefined, defaultExpanded: item.expanded } : {}),
|
|
58
60
|
...(item.children ? { children: links(item.children) } : {}),
|
|
59
61
|
...(item.actions ? { actions: links(item.actions) } : {}),
|
|
60
62
|
}));
|
|
@@ -903,36 +903,85 @@
|
|
|
903
903
|
}
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
-
/* MinimalLayout
|
|
907
|
-
|
|
908
|
-
.
|
|
909
|
-
|
|
910
|
-
|
|
906
|
+
/* MinimalLayout stacks the page content and its footer in a column at least
|
|
907
|
+
one viewport tall, so the footer ends a short page and follows a long one.
|
|
908
|
+
Content that should fill the remaining height grows with flex-1. */
|
|
909
|
+
.minimal-layout {
|
|
910
|
+
display: flex;
|
|
911
|
+
min-height: 100vh;
|
|
912
|
+
min-height: 100dvh;
|
|
913
|
+
flex-direction: column;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.minimal-layout-footer {
|
|
917
|
+
display: flex;
|
|
918
|
+
flex-wrap: wrap;
|
|
919
|
+
align-items: center;
|
|
920
|
+
justify-content: center;
|
|
921
|
+
gap: 0.25rem 1rem;
|
|
922
|
+
margin-top: auto;
|
|
923
|
+
padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom))
|
|
924
|
+
max(1rem, env(safe-area-inset-left));
|
|
925
|
+
color: var(--k2b-text-muted);
|
|
926
|
+
font-size: 0.75rem;
|
|
927
|
+
line-height: 1rem;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
.minimal-layout-footer__links,
|
|
931
|
+
.minimal-layout-footer__preferences {
|
|
932
|
+
display: flex;
|
|
933
|
+
flex-wrap: wrap;
|
|
934
|
+
align-items: center;
|
|
935
|
+
justify-content: center;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
.minimal-layout-footer__links {
|
|
939
|
+
column-gap: 1rem;
|
|
911
940
|
}
|
|
912
941
|
|
|
913
|
-
.minimal-layout-
|
|
914
|
-
|
|
915
|
-
left: max(0.75rem, env(safe-area-inset-left));
|
|
942
|
+
.minimal-layout-footer__preferences {
|
|
943
|
+
gap: 0.25rem;
|
|
916
944
|
}
|
|
917
945
|
|
|
918
|
-
.minimal-layout-
|
|
919
|
-
|
|
920
|
-
|
|
946
|
+
.minimal-layout-footer__links a {
|
|
947
|
+
display: inline-flex;
|
|
948
|
+
min-height: 1.75rem;
|
|
949
|
+
align-items: center;
|
|
950
|
+
border-radius: var(--k2b-radius-control);
|
|
951
|
+
color: inherit;
|
|
952
|
+
transition: color 150ms ease-out;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
.minimal-layout-footer__links a:hover {
|
|
956
|
+
color: var(--k2b-text);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.minimal-layout-footer__links a:focus-visible {
|
|
960
|
+
outline: 2px solid var(--k2b-focus-ring);
|
|
961
|
+
outline-offset: 2px;
|
|
921
962
|
}
|
|
922
963
|
|
|
923
|
-
.minimal-layout-
|
|
924
|
-
|
|
925
|
-
|
|
964
|
+
.k2b-ui .minimal-layout-footer .k2b-button[data-size] {
|
|
965
|
+
color: inherit;
|
|
966
|
+
font-size: inherit;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
.k2b-ui .minimal-layout-footer .k2b-button[data-size]:hover {
|
|
970
|
+
color: var(--k2b-text);
|
|
926
971
|
}
|
|
927
972
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
973
|
+
/* Finger-sized footer targets on touch screens. */
|
|
974
|
+
@media (any-pointer: coarse) {
|
|
975
|
+
.minimal-layout-footer__links a,
|
|
976
|
+
.k2b-ui .minimal-layout-footer .k2b-button[data-size] {
|
|
977
|
+
min-height: 2.75rem;
|
|
978
|
+
}
|
|
931
979
|
}
|
|
932
980
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
981
|
+
@media (prefers-reduced-motion: reduce) {
|
|
982
|
+
.minimal-layout-footer__links a {
|
|
983
|
+
transition: none;
|
|
984
|
+
}
|
|
936
985
|
}
|
|
937
986
|
|
|
938
987
|
/* Workspace navigation chrome and resource identity. */
|