@valentinkolb/cloud 0.4.0 → 0.5.1

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.
Files changed (194) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +116 -13
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/notifications/index.ts +82 -11
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +79 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +58 -0
  92. package/src/shared/redirect.ts +56 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -1,80 +1,13 @@
1
- import { For, Show, createMemo } from "solid-js";
2
1
  import { hotkeys } from "@valentinkolb/stdlib/solid";
3
- import { prompts } from "../ui";
4
- import { openGlobalSearchHelpDialog, type GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
5
-
6
- const ShortcutsDialog = (props: { openSearchHelp: () => void }) => {
7
- const entries = createMemo(() =>
8
- [...hotkeys.entries()].sort((a, b) => {
9
- const labelSort = a.label.localeCompare(b.label);
10
- return labelSort !== 0 ? labelSort : a.keys.localeCompare(b.keys);
11
- }),
12
- );
13
-
14
- return (
15
- <div class="flex flex-col gap-3">
16
- <p class="text-xs text-dimmed leading-relaxed">
17
- Use these keyboard shortcuts to work faster. The list updates automatically depending on which app or view is currently open.
18
- </p>
19
- <p class="text-xs text-dimmed leading-relaxed">
20
- Looking for the Spotlight/search{" "}
21
- <button type="button" class="text-blue-500 hover:underline dark:text-blue-400" onClick={props.openSearchHelp}>
22
- help
23
- </button>
24
- </p>
25
-
26
- <div class="max-h-[60vh] overflow-y-auto pr-1">
27
- <div class="flex flex-col gap-2">
28
- <For each={entries()}>
29
- {(entry) => (
30
- <div class="rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-2.5 bg-zinc-50/50 dark:bg-zinc-900/35">
31
- <div class="flex items-start justify-between gap-3">
32
- <div class="min-w-0">
33
- <p class="text-sm font-medium text-primary truncate">{entry.label}</p>
34
- <p class="text-xs text-dimmed mt-0.5">{entry.desc || "No description provided."}</p>
35
- </div>
36
- <div
37
- class="flex items-center gap-1.5 shrink-0"
38
- role="group"
39
- aria-label={entry.keysPretty.map((part) => part.ariaLabel).join(" + ")}
40
- >
41
- <For each={entry.keysPretty}>
42
- {(part) => (
43
- <kbd class="inline-flex min-w-6 justify-center px-1.5 py-1 rounded-md text-[11px] leading-none font-medium ring-1 ring-inset ring-zinc-300 dark:ring-zinc-700 bg-white dark:bg-zinc-900 text-primary">
44
- {part.key}
45
- </kbd>
46
- )}
47
- </For>
48
- </div>
49
- </div>
50
- </div>
51
- )}
52
- </For>
53
- <Show when={entries().length === 0}>
54
- <div class="rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-3 text-xs text-dimmed bg-zinc-50/50 dark:bg-zinc-900/35">
55
- No shortcuts registered yet.
56
- </div>
57
- </Show>
58
- </div>
59
- </div>
60
-
61
- </div>
62
- );
63
- };
2
+ import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
3
+ import { openLayoutHelpDialog } from "./LayoutHelp";
64
4
 
65
5
  /** Help action in rail nav: opens a modal with all currently registered hotkeys. */
66
6
  export default function HotkeysHelpRail(props: { searchHelpApps?: GlobalSearchHelpApp[] }) {
67
7
  const searchHelpApps = props.searchHelpApps ?? [];
68
8
 
69
9
  const openHelp = () => {
70
- void prompts.dialog<void>((close) => <ShortcutsDialog openSearchHelp={() => {
71
- close();
72
- queueMicrotask(() => openGlobalSearchHelpDialog(searchHelpApps));
73
- }} />, {
74
- title: "Keyboard Shortcuts",
75
- icon: "ti ti-keyboard",
76
- size: "large",
77
- });
10
+ openLayoutHelpDialog(searchHelpApps);
78
11
  };
79
12
 
80
13
  hotkeys.create(() => ({
@@ -1,22 +1,30 @@
1
- import { hasRole, type User } from "../contracts/shared";
2
1
  import type { JSX } from "solid-js/jsx-runtime";
3
- import NavMenu from "./NavMenu.island";
4
- import MoreAppsDropdown from "./MoreAppsDropdown.island";
5
- import ThemeToggleRail from "./ThemeToggleRail.island";
6
- import HotkeysHelpRail from "./HotkeysHelpRail.island";
7
- import GlobalSearchTrigger from "./GlobalSearchTrigger.island";
8
- import Footer from "./Footer.island";
9
- import { dates } from "../shared";
10
- import { getRuntimeContext, type RuntimeContext } from "./runtime";
11
2
  import { resolveNavMatch } from "../contracts/app"; // ==========================
3
+ import { hasRole, type User } from "../contracts/shared";
4
+ import type { LayoutAnnouncementsState } from "../server/middleware/settings";
5
+ import { dates } from "../shared";
6
+ import { readThemeFromCookieHeader } from "../shared/theme";
7
+ import type { LayoutBreadcrumb } from "../ui/layout";
8
+ import AppLaunchpad, { type AppLaunchpadApp } from "./AppLaunchpad.island";
9
+ import Footer from "./Footer.island";
10
+ import GlobalAnnouncements from "./GlobalAnnouncements.island";
12
11
  import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
12
+ import GlobalSearchTrigger from "./GlobalSearchTrigger.island";
13
+ import HotkeysHelpRail from "./HotkeysHelpRail.island";
14
+ import LayoutBreadcrumbs from "./LayoutBreadcrumbs.island";
15
+ import NavMenu from "./NavMenu.island";
16
+ import { getRuntimeContext, type RuntimeContext } from "./runtime";
17
+ import ThemeToggleRail from "./ThemeToggleRail.island";
18
+ import TimezoneCookie from "./TimezoneCookie.island";
19
+
13
20
  // Types
14
- type Breadcrumb = { title: string; href?: string };
15
- type AppLink = { iconClass: string; label: string; href: string; match: string };
21
+ type Breadcrumb = LayoutBreadcrumb;
22
+ type AppLink = { id: string; iconClass: string; label: string; href: string; match: string; description?: string };
16
23
  type LayoutContext = {
17
24
  get(key: "user"): User | undefined;
18
25
  get(key: "page"): { theme?: "light" | "dark" };
19
26
  get(key: "runtime"): RuntimeContext;
27
+ get(key: "announcements"): LayoutAnnouncementsState | undefined;
20
28
  /**
21
29
  * Per-request settings snapshot (populated by snapshot middleware in
22
30
  * `_internal/define-app.ts`). Loose-typed at this layer so Layout can be
@@ -38,6 +46,8 @@ type LayoutProps = {
38
46
  function active(pathname: string, match: string): string {
39
47
  return pathname.startsWith(match) ? "active" : "";
40
48
  }
49
+ const jsonScript = (value: unknown): string => JSON.stringify(value).replace(/</g, "\\u003c");
50
+
41
51
  function buildNavLinks(apps: RuntimeContext["apps"], user: User | undefined): { primary: AppLink[]; more: AppLink[] } {
42
52
  const links = apps
43
53
  .filter((app) => !!app.nav && app.nav.section !== "hidden")
@@ -59,15 +69,24 @@ function buildNavLinks(apps: RuntimeContext["apps"], user: User | undefined): {
59
69
  section: app.nav!.section,
60
70
  link: {
61
71
  iconClass: app.icon,
72
+ id: app.id,
62
73
  label: app.name,
63
74
  href: app.nav!.href,
64
75
  match: resolveNavMatch(app) ?? app.nav!.href.split("?")[0] ?? app.nav!.href,
76
+ description: app.description,
65
77
  } satisfies AppLink,
66
78
  }));
67
79
  const primary = links.filter((entry) => entry.section === "primary").map((entry) => entry.link);
68
80
  const more = links.filter((entry) => entry.section === "more").map((entry) => entry.link);
69
81
  if (user && hasRole(user, "admin")) {
70
- more.push({ iconClass: "ti ti-settings", label: "Admin", href: "/admin", match: "/admin" });
82
+ more.push({
83
+ id: "admin",
84
+ iconClass: "ti ti-settings",
85
+ label: "Admin",
86
+ href: "/admin",
87
+ match: "/admin",
88
+ description: "Platform administration.",
89
+ });
71
90
  }
72
91
  return { primary, more };
73
92
  } // ==========================
@@ -81,9 +100,9 @@ function ProfileWarnings({ user }: { user: User }) {
81
100
  if (!user.sn) missing.push("last name");
82
101
  if (missing.length === 0) return null;
83
102
  return (
84
- <a href="/me" class="flex items-center gap-2 text-xs info-block-warning no-underline mb-2 md:mb-1.5 mx-2 md:ml-0 md:mr-1.5">
85
- <i class="ti ti-user-exclamation" /> <span>Your profile is incomplete: {missing.join(",")} not set.</span>
86
- </a>
103
+ <a href="/me" class="flex items-center gap-2 text-xs info-block-warning no-underline mb-2 md:mb-1.5 mx-2 md:ml-0 md:mr-1.5">
104
+ <i class="ti ti-user-exclamation" /> <span>Your profile is incomplete: {missing.join(",")} not set.</span>
105
+ </a>
87
106
  );
88
107
  }
89
108
  function ExpiryWarnings({ user }: { user: User }) {
@@ -122,41 +141,23 @@ function ExpiryWarnings({ user }: { user: User }) {
122
141
  );
123
142
  } // ==========================
124
143
  // Sub-Components
125
- function BreadcrumbNav({ breadcrumbs }: { breadcrumbs: Breadcrumb[] }) {
126
- return (
127
- <nav class="flex items-center gap-1 sm:gap-2 min-w-0 text-sm md:text-xs">
128
- {" "}
129
- {breadcrumbs.map((crumb, i) => {
130
- const isLast = i === breadcrumbs.length - 1;
131
- return (
132
- <>
133
- {" "}
134
- {i > 0 && <span class="text-zinc-400 dark:text-zinc-600 text-xs">/</span>}{" "}
135
- {crumb.href && !isLast ? (
136
- <a href={crumb.href} class="text-dimmed hover:text-primary truncate">
137
- {" "}
138
- {crumb.title}{" "}
139
- </a>
140
- ) : (
141
- <span class="font-semibold text-primary truncate">{crumb.title}</span>
142
- )}{" "}
143
- </>
144
- );
145
- })}{" "}
146
- </nav>
147
- );
148
- } // ==========================
144
+ // ==========================
149
145
  // Main Layout
150
146
  export default function Layout({ children, c, title, fullPage, fullWidth }: LayoutProps) {
151
147
  const runtime = getRuntimeContext(c);
152
148
  const cookie = c.req.raw.headers.get("Cookie") ?? "";
153
- const themeMatch = cookie.match(/theme=([^;]+)/);
154
- c.get("page").theme = themeMatch?.[1] === "dark" ? "dark" : "light";
149
+ c.get("page").theme = readThemeFromCookieHeader(cookie);
155
150
  const user = c.get("user");
156
151
  const pathname = new URL(c.req.raw.url).pathname;
157
152
  const { primary: primaryApps, more: moreApps } = buildNavLinks(runtime.apps, user);
158
153
  const allApps = [...primaryApps, ...moreApps];
159
- const mobileApps = allApps.filter((app) => app.href !== "/admin");
154
+ const launchpadApps: AppLaunchpadApp[] = allApps.map((app) => ({
155
+ id: app.id,
156
+ iconClass: app.iconClass,
157
+ label: app.label,
158
+ href: app.href,
159
+ description: app.description,
160
+ }));
160
161
  const searchHelpApps: GlobalSearchHelpApp[] = runtime.apps
161
162
  .filter((app) => (app.searchTags?.length ?? 0) > 0)
162
163
  .map((app) => ({
@@ -169,6 +170,7 @@ export default function Layout({ children, c, title, fullPage, fullWidth }: Layo
169
170
  }))
170
171
  .sort((a, b) => a.appName.localeCompare(b.appName));
171
172
  const settings = c.get("settings");
173
+ const announcements = c.get("announcements");
172
174
  const appName = settings?.app?.name || "Cloud";
173
175
  // Project the user record down to what NavMenu actually renders. Without
174
176
  // this, the full `User` (mail, ssh keys, phone, address, all group
@@ -185,13 +187,15 @@ export default function Layout({ children, c, title, fullPage, fullWidth }: Layo
185
187
  // Aggregate legalLinks from every running app (last-wins on duplicate href).
186
188
  const legalLinks = (() => {
187
189
  const seen = new Map<string, { label: string; href: string; icon?: string }>();
190
+ if (user) seen.set("/me", { label: "Profile", href: "/me", icon: "ti ti-user-circle" });
188
191
  for (const app of runtime.apps) {
189
192
  for (const link of app.legalLinks ?? []) seen.set(link.href, { ...link });
190
193
  }
191
194
  return [...seen.values()];
192
195
  })();
193
196
  const page = c.get("page") as Record<string, unknown>;
194
- if (!page.title) page.title = typeof title === "string" ? title : appName;
197
+ const pageTitle = typeof title === "string" ? title : (title?.at(-1)?.title ?? appName);
198
+ if (!page.title) page.title = pageTitle;
195
199
  const breadcrumbs: Breadcrumb[] = !title ? [{ title: appName }] : typeof title === "string" ? [{ title }] : title;
196
200
  const showRail =
197
201
  !!user; /* * Grid layout: * Rail mode: [rail | content] * No rail: [content] * * Rows: [header] [main] [footer?] * The rail spans rows 1+2 via grid-row, so logo aligns with the header. */
@@ -200,10 +204,14 @@ export default function Layout({ children, c, title, fullPage, fullWidth }: Layo
200
204
  ? "grid-cols-1 md:grid-cols-[auto_1fr] grid-rows-[auto_1fr]"
201
205
  : `grid-cols-1 ${!fullPage ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[auto_1fr]"}`;
202
206
  return (
203
- <div
204
- class={`grid min-h-screen w-screen relative md:h-screen md:overflow-hidden bg-zinc-50 dark:bg-zinc-950 ${gridClass}`}
205
- >
206
- {" "}
207
+ <div class={`grid min-h-screen w-screen relative md:h-screen md:overflow-hidden bg-zinc-50 dark:bg-zinc-950 ${gridClass}`}>
208
+ <TimezoneCookie />
209
+ {showRail && <AppLaunchpad apps={launchpadApps} legalLinks={legalLinks} />}
210
+ {showRail && (
211
+ <script id="cloud-app-launchpad-data" type="application/json">
212
+ {jsonScript({ apps: launchpadApps, legalLinks })}
213
+ </script>
214
+ )}{" "}
207
215
  {/* ── Rail: logo cell (row 1, col 1) — grid gives it the same height as the header ── */}{" "}
208
216
  {showRail && (
209
217
  <div class="hidden md:flex items-center justify-center w-12 bg-white/20 dark:bg-zinc-950/20">
@@ -241,24 +249,18 @@ export default function Layout({ children, c, title, fullPage, fullWidth }: Layo
241
249
  {/* Breadcrumbs — desktop, rail mode only */}{" "}
242
250
  <div class="hidden md:flex items-center min-w-0">
243
251
  {" "}
244
- <BreadcrumbNav breadcrumbs={breadcrumbs} />{" "}
252
+ <LayoutBreadcrumbs breadcrumbs={breadcrumbs} />{" "}
245
253
  </div>{" "}
246
254
  {/* Mobile breadcrumb */}{" "}
247
255
  <div class="md:hidden flex items-center min-w-0">
248
256
  {" "}
249
- <BreadcrumbNav breadcrumbs={breadcrumbs.slice(-1)} />{" "}
257
+ <LayoutBreadcrumbs breadcrumbs={breadcrumbs} mobile />{" "}
250
258
  </div>{" "}
251
259
  </div>{" "}
252
260
  <div class="flex items-center shrink-0 gap-1">
253
261
  {user && (
254
- <GlobalSearchTrigger
255
- variant="header"
256
- registerHotkey
257
- class={showRail ? "md:hidden" : ""}
258
- searchHelpApps={searchHelpApps}
259
- />
260
- )}
261
- {" "}
262
+ <GlobalSearchTrigger variant="header" registerHotkey class={showRail ? "md:hidden" : ""} searchHelpApps={searchHelpApps} />
263
+ )}{" "}
262
264
  {/* Desktop: direct /me link with avatar (logged in) or NavMenu (not logged in) */}{" "}
263
265
  {user ? (
264
266
  <>
@@ -272,11 +274,13 @@ export default function Layout({ children, c, title, fullPage, fullWidth }: Layo
272
274
  </a>{" "}
273
275
  <div class="md:hidden">
274
276
  {" "}
275
- <NavMenu user={navMenuUser} mobileApps={mobileApps} />{" "}
277
+ <div class="flex items-center gap-1">
278
+ <AppLaunchpad apps={launchpadApps} legalLinks={legalLinks} variant="header" label="Open apps" />
279
+ </div>{" "}
276
280
  </div>{" "}
277
281
  </>
278
282
  ) : (
279
- <NavMenu user={navMenuUser} mobileApps={mobileApps} />
283
+ <NavMenu user={navMenuUser} />
280
284
  )}{" "}
281
285
  </div>{" "}
282
286
  </header>{" "}
@@ -290,12 +294,10 @@ export default function Layout({ children, c, title, fullPage, fullWidth }: Layo
290
294
  <i class={`${app.iconClass} text-base`} />{" "}
291
295
  </a>
292
296
  ))}{" "}
293
- <MoreAppsDropdown apps={moreApps} legalLinks={legalLinks} />
297
+ <AppLaunchpad apps={launchpadApps} legalLinks={legalLinks} variant="rail" label="Open apps" />
294
298
  <div class="mt-auto pb-1 flex flex-col items-center gap-1">
295
299
  {" "}
296
- <GlobalSearchTrigger variant="rail" searchHelpApps={searchHelpApps} />{" "}
297
- {" "}
298
- <HotkeysHelpRail searchHelpApps={searchHelpApps} />{" "}
300
+ <GlobalSearchTrigger variant="rail" searchHelpApps={searchHelpApps} /> <HotkeysHelpRail searchHelpApps={searchHelpApps} />{" "}
299
301
  <ThemeToggleRail />{" "}
300
302
  </div>{" "}
301
303
  </div>
@@ -303,10 +305,16 @@ export default function Layout({ children, c, title, fullPage, fullWidth }: Layo
303
305
  {/* ── Main content (row 2) ── */}{" "}
304
306
  <div class="flex flex-col min-h-0 min-w-0 bg-zinc-50 dark:bg-zinc-950">
305
307
  {" "}
308
+ {user && announcements && (
309
+ <GlobalAnnouncements
310
+ banners={announcements.banners}
311
+ announcements={announcements.announcements}
312
+ latestAnnouncementVersion={announcements.latestAnnouncementVersion}
313
+ cookieState={announcements.cookieState}
314
+ />
315
+ )}{" "}
306
316
  {user && <ProfileWarnings user={user} />} {user && <ExpiryWarnings user={user} />}{" "}
307
- <main
308
- class={`flex-1 min-h-0 ${contentPadding} ${fullPage || fullWidth ? "md:overflow-hidden flex flex-col" : "md:overflow-auto"}`}
309
- >
317
+ <main class={`flex-1 min-h-0 ${contentPadding} ${fullPage || fullWidth ? "md:overflow-hidden flex flex-col" : "md:overflow-auto"}`}>
310
318
  {" "}
311
319
  {children}{" "}
312
320
  </main>{" "}
@@ -0,0 +1,44 @@
1
+ import { createSignal, For, onCleanup, onMount } from "solid-js";
2
+ import { LAYOUT_UPDATE_EVENT, type LayoutBreadcrumb, type LayoutUpdate } from "../ui/layout";
3
+
4
+ type Props = {
5
+ breadcrumbs: LayoutBreadcrumb[];
6
+ mobile?: boolean;
7
+ };
8
+
9
+ export default function LayoutBreadcrumbs(props: Props) {
10
+ const [breadcrumbs, setBreadcrumbs] = createSignal(props.breadcrumbs);
11
+ const visibleBreadcrumbs = () => (props.mobile ? breadcrumbs().slice(-1) : breadcrumbs());
12
+
13
+ onMount(() => {
14
+ const onUpdate = (event: Event) => {
15
+ const detail = (event as CustomEvent<LayoutUpdate>).detail;
16
+ if (detail.breadcrumbs) setBreadcrumbs(detail.breadcrumbs);
17
+ };
18
+
19
+ window.addEventListener(LAYOUT_UPDATE_EVENT, onUpdate);
20
+ onCleanup(() => window.removeEventListener(LAYOUT_UPDATE_EVENT, onUpdate));
21
+ });
22
+
23
+ return (
24
+ <nav class="flex items-center gap-1 sm:gap-2 min-w-0 text-sm md:text-xs">
25
+ <For each={visibleBreadcrumbs()}>
26
+ {(crumb, i) => {
27
+ const isLast = () => i() === visibleBreadcrumbs().length - 1;
28
+ return (
29
+ <>
30
+ {i() > 0 && <span class="text-zinc-400 dark:text-zinc-600 text-xs">/</span>}
31
+ {crumb.href && !isLast() ? (
32
+ <a href={crumb.href} class="text-dimmed hover:text-primary truncate">
33
+ {crumb.title}
34
+ </a>
35
+ ) : (
36
+ <span class="font-semibold text-primary truncate">{crumb.title}</span>
37
+ )}
38
+ </>
39
+ );
40
+ }}
41
+ </For>
42
+ </nav>
43
+ );
44
+ }
@@ -0,0 +1,266 @@
1
+ import { children, createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js";
2
+ import { hotkeys } from "@valentinkolb/stdlib/solid";
3
+ import { prompts } from "../ui";
4
+ import { openGlobalSearchHelpDialog, type GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
5
+
6
+ export type LayoutHelpTab = {
7
+ id: string;
8
+ title: string;
9
+ icon?: string;
10
+ description?: string;
11
+ order?: number;
12
+ children: JSX.Element;
13
+ };
14
+
15
+ export type LayoutHelpProps = LayoutHelpTab;
16
+
17
+ const HELP_TABS_EVENT = "cloud:layout-help-tabs";
18
+ const LAST_TAB_KEY = "cloud.layoutHelp.activeTab";
19
+
20
+ declare global {
21
+ interface Window {
22
+ __cloudLayoutHelpTabs?: Map<string, LayoutHelpTab>;
23
+ }
24
+ }
25
+
26
+ const getRegistry = () => {
27
+ if (typeof window === "undefined") return null;
28
+ window.__cloudLayoutHelpTabs ??= new Map<string, LayoutHelpTab>();
29
+ return window.__cloudLayoutHelpTabs;
30
+ };
31
+
32
+ const emitTabsChanged = () => {
33
+ if (typeof window !== "undefined") window.dispatchEvent(new Event(HELP_TABS_EVENT));
34
+ };
35
+
36
+ const readLastTab = () => {
37
+ if (typeof window === "undefined") return null;
38
+ try {
39
+ return window.localStorage.getItem(LAST_TAB_KEY);
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+
45
+ const writeLastTab = (id: string) => {
46
+ if (typeof window === "undefined") return;
47
+ try {
48
+ window.localStorage.setItem(LAST_TAB_KEY, id);
49
+ } catch {
50
+ // Help still works if localStorage is blocked.
51
+ }
52
+ };
53
+
54
+ const registeredTabs = () => {
55
+ const registry = getRegistry();
56
+ if (!registry) return [];
57
+ return [...registry.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));
58
+ };
59
+
60
+ const iconClass = (icon?: string) => (icon?.startsWith("ti ") ? icon : `ti ${icon ?? "ti-circle"}`);
61
+
62
+ export function registerLayoutHelpTab(tab: LayoutHelpTab) {
63
+ const registry = getRegistry();
64
+ if (!registry) return () => {};
65
+ registry.set(tab.id, tab);
66
+ emitTabsChanged();
67
+ return () => {
68
+ if (registry.get(tab.id) === tab) {
69
+ registry.delete(tab.id);
70
+ emitTabsChanged();
71
+ }
72
+ };
73
+ }
74
+
75
+ export function LayoutHelp(props: LayoutHelpProps) {
76
+ const resolved = children(() => props.children);
77
+
78
+ onMount(() => {
79
+ const dispose = registerLayoutHelpTab({
80
+ id: props.id,
81
+ title: props.title,
82
+ icon: props.icon,
83
+ description: props.description,
84
+ order: props.order,
85
+ children: resolved(),
86
+ });
87
+ onCleanup(dispose);
88
+ });
89
+
90
+ return null;
91
+ }
92
+
93
+ const ShortcutsHelp = (props: { openSearchHelp: () => void }) => {
94
+ const entries = createMemo(() =>
95
+ [...hotkeys.entries()].sort((a, b) => {
96
+ const labelSort = a.label.localeCompare(b.label);
97
+ return labelSort !== 0 ? labelSort : a.keys.localeCompare(b.keys);
98
+ }),
99
+ );
100
+
101
+ return (
102
+ <div class="space-y-4">
103
+ <div class="info-block-info flex items-start gap-2 text-xs">
104
+ <i class="ti ti-info-circle mt-0.5 shrink-0" />
105
+ <span>Shortcuts change with the current app and view. This list updates automatically.</span>
106
+ </div>
107
+
108
+ <button
109
+ type="button"
110
+ class="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-300 dark:hover:bg-blue-950/40"
111
+ onClick={props.openSearchHelp}
112
+ >
113
+ <i class="ti ti-search text-sm" />
114
+ Show search help
115
+ </button>
116
+
117
+ <div class="flex flex-col gap-2">
118
+ <For each={entries()}>
119
+ {(entry) => (
120
+ <div class="rounded-lg bg-zinc-50/80 p-2.5 ring-1 ring-inset ring-zinc-200 dark:bg-zinc-900/45 dark:ring-zinc-800">
121
+ <div class="flex items-start justify-between gap-3">
122
+ <div class="min-w-0">
123
+ <p class="truncate text-sm font-medium text-primary">{entry.label}</p>
124
+ <p class="mt-0.5 text-xs text-dimmed">{entry.desc || "No description provided."}</p>
125
+ </div>
126
+ <div class="flex shrink-0 items-center gap-1.5" role="group" aria-label={entry.keysPretty.map((part) => part.ariaLabel).join(" + ")}>
127
+ <For each={entry.keysPretty}>
128
+ {(part) => (
129
+ <kbd class="inline-flex min-w-6 justify-center rounded-md bg-white px-1.5 py-1 text-[11px] font-medium leading-none text-primary ring-1 ring-inset ring-zinc-300 dark:bg-zinc-950 dark:ring-zinc-700">
130
+ {part.key}
131
+ </kbd>
132
+ )}
133
+ </For>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ )}
138
+ </For>
139
+ <Show when={entries().length === 0}>
140
+ <div class="rounded-lg bg-zinc-50/80 p-3 text-xs text-dimmed ring-1 ring-inset ring-zinc-200 dark:bg-zinc-900/45 dark:ring-zinc-800">
141
+ No shortcuts registered yet.
142
+ </div>
143
+ </Show>
144
+ </div>
145
+ </div>
146
+ );
147
+ };
148
+
149
+ const LayoutHelpDialog = (props: { close: () => void; searchHelpApps: GlobalSearchHelpApp[] }) => {
150
+ const [externalTabs, setExternalTabs] = createSignal(registeredTabs());
151
+ const allTabs = createMemo<LayoutHelpTab[]>(() => [
152
+ {
153
+ id: "shortcuts",
154
+ title: "Shortcuts",
155
+ icon: "ti ti-keyboard",
156
+ description: "Keyboard actions for the current page.",
157
+ order: 0,
158
+ children: (
159
+ <ShortcutsHelp
160
+ openSearchHelp={() => {
161
+ props.close();
162
+ queueMicrotask(() => openGlobalSearchHelpDialog(props.searchHelpApps));
163
+ }}
164
+ />
165
+ ),
166
+ },
167
+ ...externalTabs(),
168
+ ]);
169
+ const initialTab = () => {
170
+ const last = readLastTab();
171
+ return allTabs().some((tab) => tab.id === last) ? last! : (allTabs()[0]?.id ?? "shortcuts");
172
+ };
173
+ const [activeId, setActiveId] = createSignal(initialTab());
174
+
175
+ onMount(() => {
176
+ const update = () => setExternalTabs(registeredTabs());
177
+ window.addEventListener(HELP_TABS_EVENT, update);
178
+ update();
179
+ onCleanup(() => window.removeEventListener(HELP_TABS_EVENT, update));
180
+ });
181
+
182
+ createEffect(() => {
183
+ const tabs = allTabs();
184
+ if (!tabs.some((tab) => tab.id === activeId())) {
185
+ const last = readLastTab();
186
+ setActiveId(tabs.some((tab) => tab.id === last) ? last! : (tabs[0]?.id ?? "shortcuts"));
187
+ }
188
+ });
189
+
190
+ const selectTab = (id: string) => {
191
+ setActiveId(id);
192
+ writeLastTab(id);
193
+ };
194
+
195
+ return (
196
+ <div class="flex h-[min(90vh,52rem)] w-full flex-col gap-3">
197
+ <div class="paper flex items-center justify-between gap-4 px-5 py-4">
198
+ <div class="flex min-w-0 items-center gap-3">
199
+ <div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white">
200
+ <i class="ti ti-help text-xl" />
201
+ </div>
202
+ <div class="min-w-0">
203
+ <h2 class="truncate text-lg font-semibold text-primary">Help</h2>
204
+ <p class="truncate text-sm text-dimmed">Shortcuts, app help, and guides.</p>
205
+ </div>
206
+ </div>
207
+ <button type="button" class="icon-btn ml-auto shrink-0" onClick={props.close} aria-label="Close help">
208
+ <i class="ti ti-x" />
209
+ </button>
210
+ </div>
211
+
212
+ <div class="grid min-h-0 flex-1 gap-3 md:grid-cols-[14rem_1fr]">
213
+ <nav class="paper flex gap-1 overflow-x-auto p-2 md:min-h-0 md:flex-col md:overflow-visible" aria-label="Help topics">
214
+ <For each={allTabs()}>
215
+ {(tab) => {
216
+ const active = () => tab.id === activeId();
217
+ return (
218
+ <button
219
+ type="button"
220
+ class={`flex min-w-40 items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition md:min-w-0 ${
221
+ active()
222
+ ? "bg-blue-50 text-blue-600 dark:bg-blue-950/45 dark:text-blue-300"
223
+ : "text-dimmed hover:bg-zinc-100 hover:text-primary dark:hover:bg-zinc-900"
224
+ }`}
225
+ onClick={() => selectTab(tab.id)}
226
+ >
227
+ <i class={`${iconClass(tab.icon)} shrink-0 text-base`} />
228
+ <span class="min-w-0 flex-1 truncate">{tab.title}</span>
229
+ </button>
230
+ );
231
+ }}
232
+ </For>
233
+ </nav>
234
+
235
+ <section class="paper min-h-0 overflow-hidden">
236
+ <For each={allTabs()}>
237
+ {(tab) => (
238
+ <div class={`${tab.id === activeId() ? "block" : "hidden"} h-full overflow-y-auto px-5 py-5 pr-4`}>
239
+ <div class="mb-5 flex items-start gap-3">
240
+ <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-600 dark:bg-zinc-900 dark:text-zinc-300">
241
+ <i class={`${iconClass(tab.icon)} text-lg`} />
242
+ </div>
243
+ <div class="min-w-0">
244
+ <h3 class="text-base font-semibold text-primary">{tab.title}</h3>
245
+ <Show when={tab.description}>
246
+ <p class="mt-0.5 text-sm text-dimmed">{tab.description}</p>
247
+ </Show>
248
+ </div>
249
+ </div>
250
+ {tab.children}
251
+ </div>
252
+ )}
253
+ </For>
254
+ </section>
255
+ </div>
256
+ </div>
257
+ );
258
+ };
259
+
260
+ export function openLayoutHelpDialog(searchHelpApps: GlobalSearchHelpApp[] = []) {
261
+ void prompts.dialog<void>((close) => <LayoutHelpDialog close={close} searchHelpApps={searchHelpApps} />, {
262
+ surface: "bare",
263
+ header: false,
264
+ size: "wide",
265
+ });
266
+ }