@valentinkolb/cloud 0.3.1 → 0.5.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 +18 -8
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +119 -47
- package/src/_internal/runtime-context.ts +1 -0
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +15 -25
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +4 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +4 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
package/src/ssr/AdminSidebar.tsx
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
{ href: "/admin", icon: "ti-
|
|
9
|
-
{ href: "/admin/
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
{
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|