@valentinkolb/cloud 0.1.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 +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- package/src/ui/widgets/index.ts +9 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type LoginBtnProps = {
|
|
2
|
+
redirectTo?: string;
|
|
3
|
+
class?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
/** Link styled as a button that navigates to the login page. */
|
|
7
|
+
export default function LoginBtn(props: LoginBtnProps) {
|
|
8
|
+
const href = props.redirectTo ? `/auth/login?redirectTo=${encodeURIComponent(props.redirectTo)}` : "/auth/login";
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<a href={href} class={props.class ?? "btn-primary"}>
|
|
12
|
+
<i class="ti ti-login" />
|
|
13
|
+
<span>Sign In</span>
|
|
14
|
+
</a>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { BaseUser } from "../../contracts/shared";
|
|
2
|
+
|
|
3
|
+
type UserViewProps = {
|
|
4
|
+
user: BaseUser;
|
|
5
|
+
showRealm?: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const badgeStyles: Record<`${"ipa" | "local"}:${"user" | "guest"}`, { bg: string; text: string; label: string }> = {
|
|
9
|
+
"ipa:user": {
|
|
10
|
+
bg: "bg-green-100 dark:bg-green-900/30",
|
|
11
|
+
text: "text-green-700 dark:text-green-400",
|
|
12
|
+
label: "IPA",
|
|
13
|
+
},
|
|
14
|
+
"ipa:guest": {
|
|
15
|
+
bg: "bg-yellow-100 dark:bg-yellow-900/30",
|
|
16
|
+
text: "text-yellow-700 dark:text-yellow-400",
|
|
17
|
+
label: "IPA Guest",
|
|
18
|
+
},
|
|
19
|
+
"local:user": {
|
|
20
|
+
bg: "bg-sky-100 dark:bg-sky-900/30",
|
|
21
|
+
text: "text-sky-700 dark:text-sky-400",
|
|
22
|
+
label: "Local",
|
|
23
|
+
},
|
|
24
|
+
"local:guest": {
|
|
25
|
+
bg: "bg-zinc-100 dark:bg-zinc-800",
|
|
26
|
+
text: "text-zinc-600 dark:text-zinc-400",
|
|
27
|
+
label: "Guest",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default function UserView(props: UserViewProps) {
|
|
32
|
+
const badge = () => badgeStyles[`${props.user.provider}:${props.user.profile}`] ?? badgeStyles["local:guest"];
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div class="flex items-start gap-3 min-w-0">
|
|
36
|
+
<div class="flex shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700 font-semibold text-zinc-600 dark:text-zinc-300 h-9 w-9 text-xs">
|
|
37
|
+
{props.user.uid.slice(0, 2).toUpperCase()}
|
|
38
|
+
</div>
|
|
39
|
+
<div class="flex flex-col gap-0.5 min-w-0">
|
|
40
|
+
<div class="flex items-center gap-2">
|
|
41
|
+
<span class="text-sm font-medium text-primary truncate">{props.user.displayName}</span>
|
|
42
|
+
{props.showRealm && badge() !== undefined && (
|
|
43
|
+
<span class={`tag ${badge()?.bg} ${badge()?.text}`}>{badge()?.label}</span>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="flex items-center gap-2 text-xs text-dimmed">
|
|
47
|
+
<span class="font-mono">{props.user.profile === "guest" ? `${props.user.uid.slice(0, 12)}...` : props.user.uid}</span>
|
|
48
|
+
{props.user.mail && (
|
|
49
|
+
<>
|
|
50
|
+
<span class="text-zinc-300 dark:text-zinc-600">|</span>
|
|
51
|
+
<span class="truncate">{props.user.mail}</span>
|
|
52
|
+
</>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ParentProps, JSX } from "solid-js";
|
|
2
|
+
import { Show, children as resolveChildren, createMemo, createSignal, onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { Portal } from "solid-js/web";
|
|
4
|
+
import type { DropdownItem } from "./Dropdown";
|
|
5
|
+
|
|
6
|
+
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
|
|
7
|
+
|
|
8
|
+
type ContextMenuAction = Extract<DropdownItem, { label: string }>;
|
|
9
|
+
type ContextMenuSection = Extract<DropdownItem, { items: unknown }>;
|
|
10
|
+
type ContextMenuElement = Extract<DropdownItem, { element: unknown }>;
|
|
11
|
+
|
|
12
|
+
export type ContextMenuProps = ParentProps<{
|
|
13
|
+
children: JSX.Element;
|
|
14
|
+
elements: DropdownItem[];
|
|
15
|
+
class?: string | ((isOpen: boolean) => string);
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
onClose?: () => void;
|
|
18
|
+
onOpen?: () => void;
|
|
19
|
+
id?: string;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
const ITEM_BASE_CLASSES =
|
|
23
|
+
"flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10";
|
|
24
|
+
|
|
25
|
+
const getVariantClasses = (variant?: "danger") =>
|
|
26
|
+
variant === "danger" ? "text-red-600 dark:text-red-400" : "text-zinc-700 dark:text-zinc-300";
|
|
27
|
+
|
|
28
|
+
const isSection = (item: DropdownItem): item is ContextMenuSection => "items" in item;
|
|
29
|
+
const isElement = (item: DropdownItem): item is ContextMenuElement => "element" in item;
|
|
30
|
+
|
|
31
|
+
const getMenuItems = (menu: HTMLDivElement | undefined) =>
|
|
32
|
+
menu ? Array.from(menu.querySelectorAll<HTMLElement>("[role='menuitem']")) : [];
|
|
33
|
+
|
|
34
|
+
export default function ContextMenu(props: ContextMenuProps) {
|
|
35
|
+
const id = props.id ?? `ctx-${crypto.randomUUID()}`;
|
|
36
|
+
const [coords, setCoords] = createSignal({ x: 0, y: 0 });
|
|
37
|
+
let menuRef: HTMLDivElement | undefined;
|
|
38
|
+
let hostRef: HTMLDivElement | undefined;
|
|
39
|
+
|
|
40
|
+
const isOpen = () => openMenuId() === id;
|
|
41
|
+
const hostClass = createMemo(() => (typeof props.class === "function" ? props.class(isOpen()) : props.class));
|
|
42
|
+
const content = resolveChildren(() => props.children);
|
|
43
|
+
|
|
44
|
+
const close = () => {
|
|
45
|
+
if (openMenuId() !== id) return;
|
|
46
|
+
setOpenMenuId(null);
|
|
47
|
+
props.onClose?.();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const focusItem = (index: number) => {
|
|
51
|
+
const items = getMenuItems(menuRef);
|
|
52
|
+
if (items.length === 0) return;
|
|
53
|
+
const next = Math.max(0, Math.min(index, items.length - 1));
|
|
54
|
+
items[next]?.focus();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const open = (event: MouseEvent) => {
|
|
58
|
+
if (props.disabled) return;
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
event.stopPropagation();
|
|
61
|
+
setCoords({ x: event.clientX, y: event.clientY });
|
|
62
|
+
if (openMenuId() !== id) {
|
|
63
|
+
setOpenMenuId(id);
|
|
64
|
+
props.onOpen?.();
|
|
65
|
+
}
|
|
66
|
+
queueMicrotask(() => focusItem(0));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
70
|
+
if (!isOpen()) return;
|
|
71
|
+
const items = getMenuItems(menuRef);
|
|
72
|
+
const currentIndex = items.findIndex((item) => item === document.activeElement);
|
|
73
|
+
switch (event.key) {
|
|
74
|
+
case "Escape":
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
close();
|
|
77
|
+
hostRef?.focus();
|
|
78
|
+
break;
|
|
79
|
+
case "ArrowDown":
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
focusItem(currentIndex + 1);
|
|
82
|
+
break;
|
|
83
|
+
case "ArrowUp":
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
focusItem(currentIndex <= 0 ? items.length - 1 : currentIndex - 1);
|
|
86
|
+
break;
|
|
87
|
+
case "Home":
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
focusItem(0);
|
|
90
|
+
break;
|
|
91
|
+
case "End":
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
focusItem(items.length - 1);
|
|
94
|
+
break;
|
|
95
|
+
case "Tab":
|
|
96
|
+
close();
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
onMount(() => {
|
|
102
|
+
const handlePointer = (event: MouseEvent) => {
|
|
103
|
+
if (!isOpen()) return;
|
|
104
|
+
const target = event.target;
|
|
105
|
+
if (target instanceof Node && (menuRef?.contains(target) || hostRef?.contains(target))) return;
|
|
106
|
+
close();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
document.addEventListener("mousedown", handlePointer);
|
|
110
|
+
document.addEventListener("contextmenu", handlePointer);
|
|
111
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
112
|
+
onCleanup(() => {
|
|
113
|
+
document.removeEventListener("mousedown", handlePointer);
|
|
114
|
+
document.removeEventListener("contextmenu", handlePointer);
|
|
115
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const renderAction = (item: ContextMenuAction) => {
|
|
120
|
+
const classes = `${ITEM_BASE_CLASSES} ${getVariantClasses(item.variant)}`;
|
|
121
|
+
const content = (
|
|
122
|
+
<>
|
|
123
|
+
{item.icon && <i class={item.icon} />}
|
|
124
|
+
<span>{item.label}</span>
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if ("href" in item && item.href) {
|
|
129
|
+
return (
|
|
130
|
+
<a
|
|
131
|
+
href={item.href}
|
|
132
|
+
target={item.external ? "_blank" : undefined}
|
|
133
|
+
rel={item.external ? "noopener noreferrer" : undefined}
|
|
134
|
+
role="menuitem"
|
|
135
|
+
tabIndex={-1}
|
|
136
|
+
class={classes}
|
|
137
|
+
onClick={close}
|
|
138
|
+
>
|
|
139
|
+
{content}
|
|
140
|
+
</a>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
role="menuitem"
|
|
148
|
+
tabIndex={-1}
|
|
149
|
+
class={classes}
|
|
150
|
+
onClick={(event) => {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
event.stopPropagation();
|
|
153
|
+
if ("action" in item && item.action) {
|
|
154
|
+
item.action();
|
|
155
|
+
}
|
|
156
|
+
close();
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{content}
|
|
160
|
+
</button>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<>
|
|
166
|
+
<div
|
|
167
|
+
ref={hostRef}
|
|
168
|
+
role="group"
|
|
169
|
+
class={hostClass()}
|
|
170
|
+
onContextMenu={open}
|
|
171
|
+
>
|
|
172
|
+
{content()}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<Show when={isOpen()}>
|
|
176
|
+
<Portal>
|
|
177
|
+
<div
|
|
178
|
+
ref={menuRef}
|
|
179
|
+
role="menu"
|
|
180
|
+
aria-label="Context menu"
|
|
181
|
+
class="fixed z-50 w-52 max-w-[min(22rem,calc(100vw-1rem))] overflow-y-auto rounded-xl border border-zinc-300/60 bg-white/95 p-0 text-zinc-900 shadow-lg ring-1 ring-black/5 backdrop-blur-sm dark:border-zinc-600/50 dark:bg-zinc-950/95 dark:text-zinc-100"
|
|
182
|
+
style={{
|
|
183
|
+
left: `${Math.min(coords().x, window.innerWidth - 220)}px`,
|
|
184
|
+
top: `${Math.min(coords().y, window.innerHeight - 320)}px`,
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{props.elements.map((item, index) =>
|
|
188
|
+
isSection(item) ? (
|
|
189
|
+
<>
|
|
190
|
+
{index > 0 && <hr class="border-white/20 dark:border-zinc-700/25" />}
|
|
191
|
+
<Show when={item.sectionLabel}>
|
|
192
|
+
<div class="px-4 pt-3 pb-1 text-xs font-medium uppercase tracking-wider text-zinc-500">{item.sectionLabel}</div>
|
|
193
|
+
</Show>
|
|
194
|
+
{item.items.map((sectionItem) => (isElement(sectionItem) ? (typeof sectionItem.element === "function" ? sectionItem.element(close) : sectionItem.element) : renderAction(sectionItem)))}
|
|
195
|
+
</>
|
|
196
|
+
) : isElement(item) ? (
|
|
197
|
+
typeof item.element === "function" ? (
|
|
198
|
+
item.element(close)
|
|
199
|
+
) : (
|
|
200
|
+
item.element
|
|
201
|
+
)
|
|
202
|
+
) : (
|
|
203
|
+
renderAction(item)
|
|
204
|
+
),
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</Portal>
|
|
208
|
+
</Show>
|
|
209
|
+
</>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
import { copyToClipboard } from "@valentinkolb/stdlib/browser";
|
|
3
|
+
|
|
4
|
+
type CopyButtonProps = {
|
|
5
|
+
/** Text to copy to clipboard */
|
|
6
|
+
text: string;
|
|
7
|
+
/** Optional label - if omitted, renders icon-only */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** Additional CSS classes */
|
|
10
|
+
class?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function CopyButton(props: CopyButtonProps) {
|
|
14
|
+
const [copied, setCopied] = createSignal(false);
|
|
15
|
+
|
|
16
|
+
const handleCopy = async () => {
|
|
17
|
+
await copyToClipboard(props.text);
|
|
18
|
+
setCopied(true);
|
|
19
|
+
setTimeout(() => setCopied(false), 2000);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<button type="button" class={props.class ?? "btn-simple text-[10px] px-1.5 py-0.5"} onClick={handleCopy}>
|
|
24
|
+
<i class={copied() ? "ti ti-check" : "ti ti-copy"} />
|
|
25
|
+
{props.label !== undefined && <span>{copied() ? "Copied" : props.label}</span>}
|
|
26
|
+
</button>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
// ==========================
|
|
4
|
+
// Types
|
|
5
|
+
// ==========================
|
|
6
|
+
|
|
7
|
+
type DropdownActionBase = {
|
|
8
|
+
icon?: string;
|
|
9
|
+
label: string;
|
|
10
|
+
variant?: "danger";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type DropdownActionClick = DropdownActionBase & {
|
|
14
|
+
action: () => void;
|
|
15
|
+
href?: never;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type DropdownActionLink = DropdownActionBase & {
|
|
19
|
+
href: string;
|
|
20
|
+
external?: boolean;
|
|
21
|
+
action?: never;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type DropdownAction = DropdownActionClick | DropdownActionLink;
|
|
25
|
+
|
|
26
|
+
type DropdownElement = {
|
|
27
|
+
element: JSX.Element | ((close: () => void) => JSX.Element);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type DropdownSection = {
|
|
31
|
+
sectionLabel?: string;
|
|
32
|
+
items: Array<DropdownAction | DropdownElement>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type DropdownItem = DropdownAction | DropdownElement | DropdownSection;
|
|
36
|
+
|
|
37
|
+
type DropdownProps = {
|
|
38
|
+
trigger: JSX.Element;
|
|
39
|
+
elements: DropdownItem[];
|
|
40
|
+
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left" | (() => "bottom-right" | "bottom-left" | "top-right" | "top-left");
|
|
41
|
+
width?: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
/** Called when the dropdown closes (click outside, escape, or programmatic) */
|
|
44
|
+
onClose?: () => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ==========================
|
|
48
|
+
// Constants
|
|
49
|
+
// ==========================
|
|
50
|
+
|
|
51
|
+
const POSITION_STYLES: Record<string, string> = {
|
|
52
|
+
"bottom-right":
|
|
53
|
+
"top: anchor(bottom); left: anchor(left); margin-top: 4px;" +
|
|
54
|
+
"position-try-fallbacks: --flip-block;" +
|
|
55
|
+
"position-try: --flip-block { bottom: anchor(top); top: auto; margin-top: 0; margin-bottom: 4px; };",
|
|
56
|
+
"bottom-left":
|
|
57
|
+
"top: anchor(bottom); right: anchor(right); margin-top: 4px;" +
|
|
58
|
+
"position-try-fallbacks: --flip-block-left;" +
|
|
59
|
+
"position-try: --flip-block-left { bottom: anchor(top); top: auto; margin-top: 0; margin-bottom: 4px; };",
|
|
60
|
+
"top-right":
|
|
61
|
+
"bottom: anchor(top); left: anchor(left); margin-bottom: 4px;" +
|
|
62
|
+
"position-try-fallbacks: --flip-block-down;" +
|
|
63
|
+
"position-try: --flip-block-down { top: anchor(bottom); bottom: auto; margin-bottom: 0; margin-top: 4px; };",
|
|
64
|
+
"top-left":
|
|
65
|
+
"bottom: anchor(top); right: anchor(right); margin-bottom: 4px;" +
|
|
66
|
+
"position-try-fallbacks: --flip-block-down-left;" +
|
|
67
|
+
"position-try: --flip-block-down-left { top: anchor(bottom); bottom: auto; margin-bottom: 0; margin-top: 4px; };",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const ITEM_BASE_CLASSES = "flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10";
|
|
71
|
+
|
|
72
|
+
// ==========================
|
|
73
|
+
// Component
|
|
74
|
+
// ==========================
|
|
75
|
+
|
|
76
|
+
/** Accessible dropdown menu with popover light-dismiss and CSS anchor positioning. */
|
|
77
|
+
export default function Dropdown(props: DropdownProps) {
|
|
78
|
+
const width = props.width ?? "w-48";
|
|
79
|
+
const anchor = `--dd-${crypto.randomUUID()}`;
|
|
80
|
+
let triggerRef!: HTMLButtonElement;
|
|
81
|
+
let popoverRef!: HTMLDivElement;
|
|
82
|
+
let isOpen = false;
|
|
83
|
+
|
|
84
|
+
const close = (): void => popoverRef?.hidePopover();
|
|
85
|
+
|
|
86
|
+
const getPositionStyle = (): string => {
|
|
87
|
+
const pos = typeof props.position === "function" ? props.position() : (props.position ?? "bottom-right");
|
|
88
|
+
return POSITION_STYLES[pos] ?? POSITION_STYLES["bottom-right"]!;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const getVariantClasses = (variant?: "danger"): string =>
|
|
92
|
+
variant === "danger" ? "text-red-600 dark:text-red-400" : "text-zinc-700 dark:text-zinc-300";
|
|
93
|
+
|
|
94
|
+
const renderItem = (item: DropdownAction | DropdownElement): JSX.Element => {
|
|
95
|
+
if ("element" in item) {
|
|
96
|
+
return typeof item.element === "function" ? item.element(close) : item.element;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const classes = `${ITEM_BASE_CLASSES} ${getVariantClasses(item.variant)}`;
|
|
100
|
+
const content = (
|
|
101
|
+
<>
|
|
102
|
+
{item.icon && <i class={item.icon} />}
|
|
103
|
+
<span>{item.label}</span>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Link variant
|
|
108
|
+
if ("href" in item && item.href) {
|
|
109
|
+
return (
|
|
110
|
+
<a
|
|
111
|
+
href={item.href}
|
|
112
|
+
target={item.external ? "_blank" : undefined}
|
|
113
|
+
rel={item.external ? "noopener noreferrer" : undefined}
|
|
114
|
+
class={classes}
|
|
115
|
+
onClick={close}
|
|
116
|
+
>
|
|
117
|
+
{content}
|
|
118
|
+
</a>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Button variant
|
|
123
|
+
return (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
class={classes}
|
|
127
|
+
onClick={(e) => {
|
|
128
|
+
e.stopPropagation();
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
(item as DropdownActionClick).action();
|
|
131
|
+
close();
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{content}
|
|
135
|
+
</button>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
class="inline-flex"
|
|
144
|
+
ref={triggerRef}
|
|
145
|
+
style={`anchor-name: ${anchor}`}
|
|
146
|
+
onClick={(e) => {
|
|
147
|
+
e.stopPropagation();
|
|
148
|
+
if (isOpen) {
|
|
149
|
+
popoverRef.hidePopover();
|
|
150
|
+
} else {
|
|
151
|
+
const base = `position-anchor: ${anchor}; position: fixed; inset: unset; margin: 0; scrollbar-gutter: auto;`;
|
|
152
|
+
popoverRef.setAttribute("style", props.className ? base : `${base} ${getPositionStyle()}`);
|
|
153
|
+
popoverRef.showPopover();
|
|
154
|
+
}
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
{props.trigger}
|
|
158
|
+
</button>
|
|
159
|
+
|
|
160
|
+
<div
|
|
161
|
+
ref={(el) => {
|
|
162
|
+
popoverRef = el;
|
|
163
|
+
el.addEventListener("toggle", (e) => {
|
|
164
|
+
const newState = (e as ToggleEvent).newState;
|
|
165
|
+
const wasOpen = isOpen;
|
|
166
|
+
isOpen = newState === "open";
|
|
167
|
+
// Call onClose when transitioning from open to closed
|
|
168
|
+
if (wasOpen && !isOpen && props.onClose) {
|
|
169
|
+
props.onClose();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}}
|
|
173
|
+
popover="auto"
|
|
174
|
+
role="menu"
|
|
175
|
+
aria-label="Dropdown menu"
|
|
176
|
+
class={`${width} overflow-y-auto max-h-[min(24rem,80dvh)] paper p-0 border! border-zinc-300/60! dark:border-zinc-600/50! ${props.className ?? ""}`}
|
|
177
|
+
>
|
|
178
|
+
{props.elements.map((item, i) =>
|
|
179
|
+
"items" in item ? (
|
|
180
|
+
<>
|
|
181
|
+
{i > 0 && <hr class="border-white/20 dark:border-zinc-700/25" />}
|
|
182
|
+
{item.sectionLabel && (
|
|
183
|
+
<div class="px-4 pt-3 pb-1 text-xs uppercase tracking-wider font-medium text-zinc-500">{item.sectionLabel}</div>
|
|
184
|
+
)}
|
|
185
|
+
{item.items.map(renderItem)}
|
|
186
|
+
</>
|
|
187
|
+
) : (
|
|
188
|
+
renderItem(item)
|
|
189
|
+
),
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
}
|