@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,34 +1,113 @@
1
1
  import type { RuntimeContext } from "./runtime";
2
2
 
3
3
  type AdminLink = { href: string; icon: string; label: string };
4
+ type AdminGroup = { label: string; links: AdminLink[] };
4
5
 
5
- const buildAdminLinks = (
6
- apps: readonly RuntimeContext["apps"][number][]
7
- ): AdminLink[] => [
8
- { href: "/admin", icon: "ti-dashboard", label: "Overview" },
9
- { href: "/admin/gateway", icon: "ti-route-scan", label: "Apps & Gateway" },
10
- ...apps
11
- .filter((app) => !!app.adminHref && app.adminHref !== "/admin/gateway")
6
+ const settingsLinks: AdminLink[] = [
7
+ { href: "/admin/settings?tab=general", icon: "ti-app-window", label: "General" },
8
+ { href: "/admin/settings?tab=user", icon: "ti-users", label: "User Management" },
9
+ { href: "/admin/settings?tab=freeipa", icon: "ti-building-fortress", label: "FreeIPA" },
10
+ { href: "/admin/settings?tab=mail", icon: "ti-mail", label: "Mail" },
11
+ { href: "/admin/settings?tab=email-templates", icon: "ti-template", label: "Email Templates" },
12
+ { href: "/admin/settings?tab=security", icon: "ti-shield-lock", label: "Security" },
13
+ { href: "/admin/settings?tab=legal", icon: "ti-file-text", label: "Legal" },
14
+ ];
15
+
16
+ const staticGroups: AdminGroup[] = [
17
+ {
18
+ label: "Gateway",
19
+ links: [
20
+ { href: "/admin/gateway/apps", icon: "ti-apps", label: "Apps" },
21
+ { href: "/admin/gateway/routes", icon: "ti-route", label: "Routes" },
22
+ ],
23
+ },
24
+ {
25
+ label: "Observability",
26
+ links: [
27
+ { href: "/admin/observability/logs", icon: "ti-list-details", label: "Logs" },
28
+ { href: "/admin/observability/telemetry", icon: "ti-chart-line", label: "Telemetry" },
29
+ { href: "/admin/observability/metrics", icon: "ti-plug", label: "Metrics" },
30
+ { href: "/admin/observability/postgres", icon: "ti-database", label: "Postgres" },
31
+ { href: "/admin/observability/redis", icon: "ti-database", label: "Redis" },
32
+ { href: "/admin/observability/alerts", icon: "ti-webhook", label: "Webhooks" },
33
+ { href: "/admin/observability/notifications", icon: "ti-bell", label: "Notifications" },
34
+ ],
35
+ },
36
+ { label: "Settings", links: settingsLinks },
37
+ ];
38
+
39
+ const consoleAdminHrefs = new Set([
40
+ "/admin/gateway",
41
+ "/admin/gateway/apps",
42
+ "/admin/gateway/routes",
43
+ "/admin/observability/logs",
44
+ "/admin/observability/telemetry",
45
+ "/admin/observability/metrics",
46
+ "/admin/observability/data",
47
+ "/admin/observability/postgres",
48
+ "/admin/observability/redis",
49
+ "/admin/observability/alerts",
50
+ "/admin/observability/notifications",
51
+ "/admin/settings",
52
+ ]);
53
+
54
+ const buildAdminGroups = (apps: readonly RuntimeContext["apps"][number][]): AdminGroup[] => {
55
+ const appLinks = apps
56
+ .filter((app) => !!app.adminHref && !consoleAdminHrefs.has(app.adminHref))
12
57
  .map((app) => ({
13
58
  href: app.adminHref!,
14
59
  icon: app.icon.replace(/^ti\s+/, ""),
15
60
  label: app.name,
16
- })),
17
- ];
61
+ }))
62
+ .sort((a, b) => a.label.localeCompare(b.label));
63
+
64
+ return [
65
+ {
66
+ label: "General",
67
+ links: [
68
+ { href: "/admin", icon: "ti-dashboard", label: "Overview" },
69
+ { href: "/admin/announcements", icon: "ti-speakerphone", label: "Announcements" },
70
+ ],
71
+ },
72
+ ...staticGroups,
73
+ ...(appLinks.length > 0 ? [{ label: "App Admin", links: appLinks }] : []),
74
+ ];
75
+ };
18
76
 
19
- function isActive(pathname: string, href: string): boolean {
20
- if (href === "/admin") return pathname === "/admin";
21
- return pathname.startsWith(href);
77
+ function isActive(currentPath: string, href: string): boolean {
78
+ const current = new URL(`http://admin.local${currentPath}`);
79
+ const target = new URL(`http://admin.local${href}`);
80
+ if (target.pathname === "/admin") return current.pathname === "/admin";
81
+ if (target.pathname === "/admin/settings") {
82
+ return current.pathname === "/admin/settings" && current.searchParams.get("tab") === target.searchParams.get("tab");
83
+ }
84
+ return current.pathname === target.pathname || current.pathname.startsWith(`${target.pathname}/`);
22
85
  }
23
86
 
24
- export default function AdminSidebar({
25
- pathname,
26
- apps,
27
- }: {
28
- pathname: string;
29
- apps: readonly RuntimeContext["apps"][number][];
30
- }) {
31
- const adminLinks = buildAdminLinks(apps);
87
+ const MobileLink = (props: { currentPath: string; link: AdminLink }) => (
88
+ <a
89
+ href={props.link.href}
90
+ class={`sidebar-item-mobile ${
91
+ isActive(props.currentPath, props.link.href)
92
+ ? "border-blue-500/35 bg-blue-50/70 text-blue-700 dark:border-blue-400/40 dark:bg-blue-950/40 dark:text-blue-200"
93
+ : ""
94
+ }`}
95
+ aria-current={isActive(props.currentPath, props.link.href) ? "page" : undefined}
96
+ >
97
+ <i class={`ti ${props.link.icon}`} />
98
+ {props.link.label}
99
+ </a>
100
+ );
101
+
102
+ const DesktopLink = (props: { currentPath: string; link: AdminLink }) => (
103
+ <a href={props.link.href} class={`sidebar-item ${isActive(props.currentPath, props.link.href) ? "sidebar-item-active" : ""}`}>
104
+ <i class={`ti ${props.link.icon} text-sm`} />
105
+ <span>{props.link.label}</span>
106
+ </a>
107
+ );
108
+
109
+ export default function AdminSidebar({ currentPath, apps }: { currentPath: string; apps: readonly RuntimeContext["apps"][number][] }) {
110
+ const groups = buildAdminGroups(apps);
32
111
 
33
112
  return (
34
113
  <>
@@ -44,28 +123,20 @@ export default function AdminSidebar({
44
123
  </span>
45
124
  </summary>
46
125
  <div class="sidebar-mobile-actions">
47
- {adminLinks.map((link) => (
48
- <a
49
- href={link.href}
50
- class={`sidebar-item-mobile ${
51
- isActive(pathname, link.href)
52
- ? "border-blue-500/35 bg-blue-50/70 text-blue-700 dark:border-blue-400/40 dark:bg-blue-950/40 dark:text-blue-200"
53
- : ""
54
- }`}
55
- aria-current={
56
- isActive(pathname, link.href) ? "page" : undefined
57
- }
58
- >
59
- <i class={`ti ${link.icon}`} />
60
- {link.label}
61
- </a>
126
+ {groups.map((group) => (
127
+ <section class="sidebar-group">
128
+ <p class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-dimmed">{group.label}</p>
129
+ {group.links.map((link) => (
130
+ <MobileLink currentPath={currentPath} link={link} />
131
+ ))}
132
+ </section>
62
133
  ))}
63
134
  </div>
64
135
  </details>
65
136
  </nav>
66
137
 
67
138
  <aside class="sidebar-container">
68
- <div class="paper flex h-full min-h-0 flex-col gap-4 p-4">
139
+ <div class="paper flex h-full min-h-0 flex-col gap-4 p-3">
69
140
  <div class="flex items-center gap-3">
70
141
  <div class="sidebar-header-icon bg-zinc-600 dark:bg-zinc-700">
71
142
  <i class="ti ti-settings text-xs" />
@@ -74,19 +145,14 @@ export default function AdminSidebar({
74
145
  </div>
75
146
 
76
147
  <div class="sidebar-body">
77
- <section class="sidebar-group">
78
- {adminLinks.map((link) => (
79
- <a
80
- href={link.href}
81
- class={`sidebar-item ${
82
- isActive(pathname, link.href) ? "sidebar-item-active" : ""
83
- }`}
84
- >
85
- <i class={`ti ${link.icon} text-sm`} />
86
- <span>{link.label}</span>
87
- </a>
88
- ))}
89
- </section>
148
+ {groups.map((group) => (
149
+ <section class="sidebar-group">
150
+ <p class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-dimmed">{group.label}</p>
151
+ {group.links.map((link) => (
152
+ <DesktopLink currentPath={currentPath} link={link} />
153
+ ))}
154
+ </section>
155
+ ))}
90
156
  </div>
91
157
  </div>
92
158
  </aside>
@@ -0,0 +1,176 @@
1
+ import { createEffect, For, Show } from "solid-js";
2
+ import { prompts } from "../ui";
3
+
4
+ export type AppLaunchpadApp = {
5
+ id: string;
6
+ iconClass: string;
7
+ label: string;
8
+ href: string;
9
+ description?: string;
10
+ };
11
+
12
+ export type AppLaunchpadLegalLink = {
13
+ label: string;
14
+ href: string;
15
+ icon?: string;
16
+ };
17
+
18
+ type AppLaunchpadContext = {
19
+ apps: AppLaunchpadApp[];
20
+ legalLinks: AppLaunchpadLegalLink[];
21
+ };
22
+
23
+ type AppIconPaletteEntry = { from: string };
24
+
25
+ const appIconPalette: readonly [AppIconPaletteEntry, ...AppIconPaletteEntry[]] = [
26
+ { from: "#2563eb" },
27
+ { from: "#059669" },
28
+ { from: "#7c3aed" },
29
+ { from: "#d97706" },
30
+ { from: "#e11d48" },
31
+ { from: "#0891b2" },
32
+ { from: "#52525b" },
33
+ ];
34
+
35
+ declare global {
36
+ interface Window {
37
+ __cloudAppLaunchpad?: AppLaunchpadContext;
38
+ cloud?: {
39
+ openAppLaunchpad?: () => void;
40
+ };
41
+ }
42
+ }
43
+
44
+ const paletteForId = (id: string) => {
45
+ let hash = 0;
46
+ for (let i = 0; i < id.length; i++) hash = (hash + id.charCodeAt(i)) % appIconPalette.length;
47
+ return appIconPalette[hash] ?? appIconPalette[0];
48
+ };
49
+
50
+ const appIconStyle = (id: string) => {
51
+ const tone = paletteForId(id);
52
+ return `--app-icon-color:${tone.from}`;
53
+ };
54
+
55
+ const readEmbeddedContext = (): AppLaunchpadContext | undefined => {
56
+ if (typeof document === "undefined") return undefined;
57
+ const element = document.getElementById("cloud-app-launchpad-data");
58
+ const text = element?.textContent;
59
+ if (!text) return undefined;
60
+
61
+ try {
62
+ const parsed = JSON.parse(text) as Partial<AppLaunchpadContext>;
63
+ if (!Array.isArray(parsed.apps)) return undefined;
64
+ return {
65
+ apps: parsed.apps,
66
+ legalLinks: Array.isArray(parsed.legalLinks) ? parsed.legalLinks : [],
67
+ };
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ };
72
+
73
+ const AppLaunchpadPanel = (props: AppLaunchpadContext) => (
74
+ <div class="launchpad-panel mx-auto max-h-[min(86vh,calc(100dvh-1.5rem))] w-[calc(100vw-1.5rem)] max-w-[calc(100vw-1.5rem)] overflow-y-auto overscroll-contain p-4 text-primary sm:w-fit sm:p-6 md:p-7 dark:text-white">
75
+ <div class="flex flex-wrap justify-center gap-x-4 gap-y-4 sm:gap-x-7 sm:gap-y-6">
76
+ <For each={props.apps}>
77
+ {(app) => (
78
+ <a
79
+ href={app.href}
80
+ class="group flex w-[4.75rem] min-w-0 flex-col items-center gap-1.5 rounded-2xl p-1 text-center outline-none focus-visible:ring-2 focus-visible:ring-white/60 sm:w-[6.25rem] sm:gap-2 sm:p-2"
81
+ >
82
+ <span
83
+ class="app-icon grid h-12 w-12 place-items-center rounded-[0.95rem] text-[1.25rem] sm:h-16 sm:w-16 sm:rounded-[1.25rem] sm:text-[1.7rem]"
84
+ style={appIconStyle(app.id)}
85
+ >
86
+ <i class={app.iconClass} />
87
+ </span>
88
+ <span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">{app.label}</span>
89
+ </a>
90
+ )}
91
+ </For>
92
+ </div>
93
+ <Show when={props.legalLinks.length > 0}>
94
+ <div class="mt-7 flex flex-wrap justify-center text-[11px] text-dimmed dark:text-white/56">
95
+ <For each={props.legalLinks}>
96
+ {(link) => (
97
+ <a href={link.href} class="inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors hover:text-primary dark:hover:text-white">
98
+ <i class={link.icon ?? "ti ti-file-text"} />
99
+ {link.label}
100
+ </a>
101
+ )}
102
+ </For>
103
+ </div>
104
+ </Show>
105
+ </div>
106
+ );
107
+
108
+ export function setAppLaunchpadContext(apps: AppLaunchpadApp[], legalLinks: AppLaunchpadLegalLink[] = []) {
109
+ if (typeof window === "undefined") return;
110
+ window.__cloudAppLaunchpad = { apps, legalLinks };
111
+ window.cloud ??= {};
112
+ window.cloud.openAppLaunchpad = () => {
113
+ openAppLaunchpad();
114
+ };
115
+ }
116
+
117
+ export function openAppLaunchpad(apps?: AppLaunchpadApp[], legalLinks?: AppLaunchpadLegalLink[]) {
118
+ if (typeof window === "undefined") return;
119
+ const context = apps ? { apps, legalLinks: legalLinks ?? [] } : (window.__cloudAppLaunchpad ?? readEmbeddedContext());
120
+ if (!context || context.apps.length === 0) return;
121
+ window.__cloudAppLaunchpad = context;
122
+ void prompts.dialog<void>(() => <AppLaunchpadPanel apps={context.apps} legalLinks={context.legalLinks} />, {
123
+ surface: "bare",
124
+ header: false,
125
+ size: "large",
126
+ });
127
+ }
128
+
129
+ export function AppLaunchpadProvider(props: AppLaunchpadContext) {
130
+ createEffect(() => {
131
+ setAppLaunchpadContext(props.apps, props.legalLinks);
132
+ });
133
+
134
+ return <span class="hidden" data-cloud-app-launchpad-provider />;
135
+ }
136
+
137
+ export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail" | "header" | "menu"; label?: string }) {
138
+ const open = () => openAppLaunchpad(props.apps, props.legalLinks);
139
+
140
+ if (props.variant === "rail") {
141
+ return (
142
+ <button type="button" class="rail-item" title={props.label ?? "Apps"} aria-label={props.label ?? "Open apps"} onClick={open}>
143
+ <i class="ti ti-grid-dots text-base" />
144
+ </button>
145
+ );
146
+ }
147
+
148
+ if (props.variant === "header") {
149
+ return (
150
+ <button type="button" class="icon-btn inline items-center justify-center" aria-label={props.label ?? "Open apps"} onClick={open}>
151
+ <i class="ti ti-grid-dots text-lg" />
152
+ </button>
153
+ );
154
+ }
155
+
156
+ return (
157
+ <button
158
+ type="button"
159
+ class="flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10 text-zinc-700 dark:text-zinc-300"
160
+ onClick={open}
161
+ >
162
+ <i class="ti ti-grid-dots" />
163
+ <span>{props.label ?? "Apps"}</span>
164
+ </button>
165
+ );
166
+ }
167
+
168
+ export function AppLaunchpad(props: AppLaunchpadContext & { variant?: "provider" | "rail" | "header" | "menu"; label?: string }) {
169
+ if (!props.variant || props.variant === "provider") {
170
+ return <AppLaunchpadProvider apps={props.apps} legalLinks={props.legalLinks} />;
171
+ }
172
+
173
+ return <AppLaunchpadButton apps={props.apps} legalLinks={props.legalLinks} variant={props.variant} label={props.label} />;
174
+ }
175
+
176
+ export default AppLaunchpad;
@@ -1,5 +1,5 @@
1
1
  import { createSignal, For, Show } from "solid-js";
2
- import { cookies } from "@valentinkolb/stdlib/browser";
2
+ import { getCurrentThemePreference, setThemePreference } from "../shared/theme";
3
3
 
4
4
  type FooterProps = {
5
5
  isLoggedIn: boolean;
@@ -13,16 +13,11 @@ type FooterProps = {
13
13
  };
14
14
 
15
15
  export default function Footer(props: FooterProps) {
16
- const [theme, setTheme] = createSignal(
17
- typeof document !== "undefined" ? (document.documentElement.classList.contains("dark") ? "dark" : "light") : "dark",
18
- );
16
+ const [theme, setTheme] = createSignal(getCurrentThemePreference());
19
17
 
20
18
  const toggleTheme = () => {
21
19
  const newTheme = theme() === "dark" ? "light" : "dark";
22
- document.documentElement.classList.remove("dark", "light");
23
- document.documentElement.classList.add(newTheme);
24
- cookies.writeCookie("theme", newTheme);
25
- setTheme(newTheme);
20
+ setTheme(setThemePreference(newTheme));
26
21
  };
27
22
 
28
23
  return (
@@ -0,0 +1,141 @@
1
+ import { createSignal, For, Show } from "solid-js";
2
+ import {
3
+ ANNOUNCEMENTS_COOKIE,
4
+ ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
5
+ type AnnouncementCookieState,
6
+ type AnnouncementDisplayEntry,
7
+ mergeAnnouncementCookieState,
8
+ serializeAnnouncementCookieState,
9
+ } from "../contracts/announcements";
10
+ import MarkdownView from "../ui/misc/MarkdownView";
11
+
12
+ type Props = {
13
+ banners: AnnouncementDisplayEntry[];
14
+ announcements: AnnouncementDisplayEntry[];
15
+ latestAnnouncementVersion: number;
16
+ cookieState: AnnouncementCookieState;
17
+ };
18
+
19
+ const writeCookieState = (state: AnnouncementCookieState) => {
20
+ document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(state)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
21
+ };
22
+
23
+ const toneClass = (tone: AnnouncementDisplayEntry["tone"]) => {
24
+ if (tone === "success")
25
+ return "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-100";
26
+ if (tone === "warning")
27
+ return "border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100";
28
+ if (tone === "danger") return "border-red-200 bg-red-50 text-red-950 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100";
29
+ return "border-blue-200 bg-blue-50 text-blue-950 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100";
30
+ };
31
+
32
+ const toneIcon = (tone: AnnouncementDisplayEntry["tone"]) => {
33
+ if (tone === "success") return "ti ti-circle-check";
34
+ if (tone === "warning") return "ti ti-alert-triangle";
35
+ if (tone === "danger") return "ti ti-alert-circle";
36
+ return "ti ti-info-circle";
37
+ };
38
+
39
+ export default function GlobalAnnouncements(props: Props) {
40
+ const [cookieState, setCookieState] = createSignal(props.cookieState);
41
+ const [banners, setBanners] = createSignal(props.banners);
42
+ const [modalOpen, setModalOpen] = createSignal(props.announcements.length > 0);
43
+
44
+ const dismissBanner = (version: number) => {
45
+ const next = mergeAnnouncementCookieState(cookieState(), { dismissedBannerVersions: [version] });
46
+ setCookieState(next);
47
+ writeCookieState(next);
48
+ setBanners((items) => items.filter((item) => item.version !== version));
49
+ };
50
+
51
+ const closeAnnouncements = () => {
52
+ const next = mergeAnnouncementCookieState(cookieState(), {
53
+ seenAnnouncementVersion: props.latestAnnouncementVersion,
54
+ });
55
+ setCookieState(next);
56
+ writeCookieState(next);
57
+ setModalOpen(false);
58
+ };
59
+
60
+ return (
61
+ <>
62
+ <Show when={banners().length > 0}>
63
+ <div class="mx-2 flex flex-col gap-1 pb-1 md:ml-0 md:mr-1.5">
64
+ <For each={banners()}>
65
+ {(banner) => (
66
+ <section
67
+ class={`flex max-h-[min(40vh,14rem)] items-start gap-2 rounded-lg border px-3 py-2 text-xs shadow-sm ${toneClass(banner.tone)}`}
68
+ >
69
+ <i class={`${toneIcon(banner.tone)} mt-0.5 shrink-0`} />
70
+ <div class="min-h-0 min-w-0 flex-1">
71
+ <p class="font-semibold">{banner.title}</p>
72
+ <MarkdownView
73
+ html={banner.bodyHtml}
74
+ smallHeadings
75
+ class="mt-1 max-h-36 overflow-y-auto overscroll-contain pr-1 [&_p]:my-0"
76
+ />
77
+ </div>
78
+ <button
79
+ type="button"
80
+ class="ml-auto inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md opacity-70 hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
81
+ aria-label="Dismiss banner"
82
+ onClick={() => dismissBanner(banner.version)}
83
+ >
84
+ <i class="ti ti-x" />
85
+ </button>
86
+ </section>
87
+ )}
88
+ </For>
89
+ </div>
90
+ </Show>
91
+
92
+ <Show when={modalOpen()}>
93
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-3 backdrop-blur-sm">
94
+ <section class="flex max-h-[86vh] w-[min(96vw,42rem)] min-h-0 flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-900 shadow-2xl dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
95
+ <header class="flex shrink-0 items-center gap-3 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
96
+ <i class="ti ti-speakerphone text-base text-blue-500" />
97
+ <div class="min-w-0">
98
+ <p class="font-semibold">Announcements</p>
99
+ <p class="text-xs text-dimmed">Latest platform updates</p>
100
+ </div>
101
+ <button type="button" class="icon-btn ml-auto" aria-label="Close announcements" onClick={closeAnnouncements}>
102
+ <i class="ti ti-x" />
103
+ </button>
104
+ </header>
105
+ <main class="min-h-0 flex-1 overflow-y-auto p-4">
106
+ <div class="flex flex-col gap-4">
107
+ <For each={props.announcements}>
108
+ {(entry) => (
109
+ <article class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
110
+ <div class="mb-3 flex items-start justify-between gap-3">
111
+ <div class="min-w-0">
112
+ <h2 class="text-base font-semibold text-primary">{entry.title}</h2>
113
+ <p class="mt-0.5 text-xs text-dimmed">
114
+ {new Date(entry.publishedAt).toLocaleDateString(undefined, {
115
+ year: "numeric",
116
+ month: "short",
117
+ day: "numeric",
118
+ })}
119
+ </p>
120
+ </div>
121
+ <span class="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-dimmed dark:bg-zinc-800">
122
+ v{entry.version}
123
+ </span>
124
+ </div>
125
+ <MarkdownView html={entry.bodyHtml} />
126
+ </article>
127
+ )}
128
+ </For>
129
+ </div>
130
+ </main>
131
+ <footer class="flex shrink-0 justify-end border-t border-zinc-200 bg-white/95 p-3 dark:border-zinc-800 dark:bg-zinc-950/95">
132
+ <button type="button" class="btn-primary btn-sm" onClick={closeAnnouncements}>
133
+ Got it
134
+ </button>
135
+ </footer>
136
+ </section>
137
+ </div>
138
+ </Show>
139
+ </>
140
+ );
141
+ }