@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.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,213 @@
1
+ import { createSignal, For, Show } from "solid-js";
2
+ import { timed } from "@valentinkolb/stdlib/solid";
3
+ import TextInput from "../input/TextInput";
4
+
5
+ type UserResult = {
6
+ id: string;
7
+ uid: string;
8
+ displayName: string;
9
+ mail: string | null;
10
+ };
11
+
12
+ type GroupResult = {
13
+ id: string;
14
+ provider: "ipa" | "local";
15
+ name: string;
16
+ description: string | null;
17
+ };
18
+
19
+ export type EntitySearchResult =
20
+ | { type: "user"; id: string; displayName: string; mail: string | null }
21
+ | { type: "group"; id: string; provider: "ipa" | "local"; name: string; description: string | null };
22
+
23
+ type EntitySearchProps = {
24
+ apiBaseUrl?: string;
25
+ groupProvider?: "ipa" | "local";
26
+ searchUsers?: boolean;
27
+ searchGroups?: boolean;
28
+ excludeUserIds?: string[];
29
+ excludeGroupIds?: string[];
30
+ onSelect: (result: EntitySearchResult) => void;
31
+ placeholder?: string;
32
+ adding?: boolean;
33
+ userMemberOfGroupIds?: string[];
34
+ resultsHeightClass?: string;
35
+ };
36
+
37
+ const EntitySearch = (props: EntitySearchProps) => {
38
+ const [search, setSearch] = createSignal("");
39
+ const [users, setUsers] = createSignal<UserResult[]>([]);
40
+ const [groups, setGroups] = createSignal<GroupResult[]>([]);
41
+ const [loading, setLoading] = createSignal(false);
42
+ const [addingId, setAddingId] = createSignal<string | null>(null);
43
+
44
+ const doSearch = async (q: string) => {
45
+ if (q.length < 2) {
46
+ setUsers([]);
47
+ setGroups([]);
48
+ return;
49
+ }
50
+
51
+ setLoading(true);
52
+ try {
53
+ const url = new URL(`${props.apiBaseUrl ?? "/api/accounts"}/entities`, window.location.origin);
54
+ const kinds = [
55
+ ...(props.searchUsers !== false ? ["user"] : []),
56
+ ...(props.searchGroups ? ["group"] : []),
57
+ ];
58
+ if (kinds.length === 0) {
59
+ setUsers([]);
60
+ setGroups([]);
61
+ return;
62
+ }
63
+
64
+ url.searchParams.set("search", q);
65
+ url.searchParams.set("kinds", kinds.join(","));
66
+ url.searchParams.set("per_page", "10");
67
+
68
+ if (props.excludeUserIds && props.excludeUserIds.length > 0) {
69
+ url.searchParams.set("exclude_user_ids", props.excludeUserIds.join(","));
70
+ }
71
+
72
+ if (props.excludeGroupIds && props.excludeGroupIds.length > 0) {
73
+ url.searchParams.set("exclude_group_ids", props.excludeGroupIds.join(","));
74
+ }
75
+
76
+ if (props.userMemberOfGroupIds && props.userMemberOfGroupIds.length > 0) {
77
+ url.searchParams.set("user_member_of_group_ids", props.userMemberOfGroupIds.join(","));
78
+ }
79
+
80
+ if (props.groupProvider) {
81
+ url.searchParams.set("provider", props.groupProvider);
82
+ }
83
+
84
+ const res = await fetch(url.toString(), {
85
+ credentials: "same-origin",
86
+ });
87
+
88
+ if (res.ok) {
89
+ const data = await res.json();
90
+ const items = data.items ?? [];
91
+ setUsers(items.filter((item: { kind: string }) => item.kind === "user").map((item: { user: UserResult }) => item.user));
92
+ setGroups(items.filter((item: { kind: string }) => item.kind === "group").map((item: { group: GroupResult }) => item.group));
93
+ }
94
+ } finally {
95
+ setLoading(false);
96
+ }
97
+ };
98
+
99
+ const { debouncedFn: debouncedSearch } = timed.debounce(doSearch, 300);
100
+
101
+ const handleInput = (value: string) => {
102
+ setSearch(value);
103
+ debouncedSearch(value);
104
+ };
105
+
106
+ const handleSelect = (result: EntitySearchResult) => {
107
+ setAddingId(result.id);
108
+ props.onSelect(result);
109
+ setAddingId(null);
110
+ };
111
+
112
+ const resultsHeightClass = () => props.resultsHeightClass ?? "h-48";
113
+
114
+ return (
115
+ <div class="flex flex-col gap-3">
116
+ <TextInput icon="ti ti-search" placeholder={props.placeholder ?? "Search..."} value={() => search()} onInput={handleInput} />
117
+
118
+ <div class={`${resultsHeightClass()} overflow-y-auto`}>
119
+ <Show when={loading()}>
120
+ <div class="flex items-center justify-center py-8 text-dimmed">
121
+ <i class="ti ti-loader-2 animate-spin text-xl" />
122
+ </div>
123
+ </Show>
124
+
125
+ <Show when={!loading() && search().length >= 2 && users().length === 0 && groups().length === 0}>
126
+ <p class="flex items-center justify-center gap-1.5 py-8 text-xs text-dimmed">
127
+ <i class="ti ti-search-off text-sm" />
128
+ No results found
129
+ </p>
130
+ </Show>
131
+
132
+ <Show when={!loading() && search().length < 2}>
133
+ <p class="flex items-center justify-center gap-1.5 py-8 text-xs text-dimmed">
134
+ <i class="ti ti-search text-sm" />
135
+ Type at least 2 characters
136
+ </p>
137
+ </Show>
138
+
139
+ <Show when={!loading() && (users().length > 0 || groups().length > 0)}>
140
+ <div class="flex flex-col gap-1">
141
+ <For each={users()}>
142
+ {(user) => (
143
+ <div class="flex items-center gap-3 rounded p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800">
144
+ <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
145
+ <i class="ti ti-user text-sm" />
146
+ </div>
147
+ <div class="min-w-0 flex-1">
148
+ <div class="truncate text-sm font-medium">{user.displayName}</div>
149
+ <div class="truncate text-xs text-dimmed">
150
+ {user.uid}
151
+ {user.mail && ` · ${user.mail}`}
152
+ </div>
153
+ </div>
154
+ <button
155
+ type="button"
156
+ onClick={() =>
157
+ handleSelect({
158
+ type: "user",
159
+ id: user.id,
160
+ displayName: user.displayName,
161
+ mail: user.mail,
162
+ })
163
+ }
164
+ disabled={addingId() !== null || props.adding}
165
+ class="rounded p-2 text-emerald-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:opacity-50 dark:hover:bg-emerald-900/20"
166
+ aria-label={`Add ${user.displayName}`}
167
+ >
168
+ <i class={addingId() === user.id ? "ti ti-loader-2 animate-spin" : "ti ti-plus"} />
169
+ </button>
170
+ </div>
171
+ )}
172
+ </For>
173
+
174
+ <For each={groups()}>
175
+ {(group) => (
176
+ <div class="flex items-center gap-3 rounded p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800">
177
+ <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
178
+ <i class="ti ti-users-group text-sm" />
179
+ </div>
180
+ <div class="min-w-0 flex-1">
181
+ <div class="truncate text-sm font-medium">{group.name}</div>
182
+ <Show when={group.description}>
183
+ <div class="truncate text-xs text-dimmed">{group.description}</div>
184
+ </Show>
185
+ </div>
186
+ <button
187
+ type="button"
188
+ onClick={() =>
189
+ handleSelect({
190
+ type: "group",
191
+ id: group.id,
192
+ provider: group.provider,
193
+ name: group.name,
194
+ description: group.description,
195
+ })
196
+ }
197
+ disabled={addingId() !== null || props.adding}
198
+ class="rounded p-2 text-emerald-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 disabled:opacity-50 dark:hover:bg-emerald-900/20"
199
+ aria-label={`Add ${group.name}`}
200
+ >
201
+ <i class={addingId() === group.id ? "ti ti-loader-2 animate-spin" : "ti ti-plus"} />
202
+ </button>
203
+ </div>
204
+ )}
205
+ </For>
206
+ </div>
207
+ </Show>
208
+ </div>
209
+ </div>
210
+ );
211
+ };
212
+
213
+ export default EntitySearch;
@@ -0,0 +1,194 @@
1
+ import { createSignal, onMount, onCleanup, Show, For } from "solid-js";
2
+
3
+ export type LightboxImage = {
4
+ src: string;
5
+ alt?: string;
6
+ downloadUrl?: string;
7
+ };
8
+
9
+ type LightboxProps = {
10
+ images: LightboxImage[];
11
+ initialIndex?: number;
12
+ onClose: () => void;
13
+ };
14
+
15
+ /**
16
+ * Minimal, accessible lightbox using native <dialog>.
17
+ * Supports keyboard navigation, touch swipe gestures, and screen readers.
18
+ */
19
+ export default function Lightbox(props: LightboxProps) {
20
+ const [index, setIndex] = createSignal(props.initialIndex ?? 0);
21
+ let dialogRef!: HTMLDialogElement;
22
+
23
+ // Touch gesture tracking
24
+ let touchStartX = 0;
25
+ let touchStartY = 0;
26
+ const SWIPE_THRESHOLD = 50;
27
+
28
+ const current = () => props.images[index()];
29
+ const isMultiple = () => props.images.length > 1;
30
+ const prev = () => {
31
+ if (!isMultiple()) return;
32
+ setIndex((i) => (i - 1 + props.images.length) % props.images.length);
33
+ };
34
+ const next = () => {
35
+ if (!isMultiple()) return;
36
+ setIndex((i) => (i + 1) % props.images.length);
37
+ };
38
+
39
+ const close = () => {
40
+ dialogRef.close();
41
+ props.onClose();
42
+ };
43
+
44
+ // Keyboard navigation
45
+ const handleKeyDown = (e: KeyboardEvent) => {
46
+ switch (e.key) {
47
+ case "Escape":
48
+ close();
49
+ break;
50
+ case "ArrowLeft":
51
+ prev();
52
+ break;
53
+ case "ArrowRight":
54
+ next();
55
+ break;
56
+ }
57
+ };
58
+
59
+ // Touch handlers for swipe gestures
60
+ const handleTouchStart = (e: TouchEvent) => {
61
+ const touch = e.touches[0];
62
+ if (touch) {
63
+ touchStartX = touch.clientX;
64
+ touchStartY = touch.clientY;
65
+ }
66
+ };
67
+
68
+ const handleTouchEnd = (e: TouchEvent) => {
69
+ const touch = e.changedTouches[0];
70
+ if (!touch) return;
71
+
72
+ const deltaX = touch.clientX - touchStartX;
73
+ const deltaY = touch.clientY - touchStartY;
74
+
75
+ // Only trigger swipe if horizontal movement is dominant
76
+ if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > SWIPE_THRESHOLD) {
77
+ if (deltaX > 0) {
78
+ prev();
79
+ } else {
80
+ next();
81
+ }
82
+ }
83
+ };
84
+
85
+ // Click on backdrop closes lightbox
86
+ const handleBackdropClick = (e: MouseEvent) => {
87
+ if (e.target === dialogRef) {
88
+ close();
89
+ }
90
+ };
91
+
92
+ onMount(() => {
93
+ dialogRef.showModal();
94
+ document.addEventListener("keydown", handleKeyDown);
95
+ });
96
+
97
+ onCleanup(() => {
98
+ document.removeEventListener("keydown", handleKeyDown);
99
+ });
100
+
101
+ return (
102
+ <dialog
103
+ ref={dialogRef}
104
+ class="fixed inset-0 m-0 h-dvh w-dvw max-h-none max-w-none bg-black/92 backdrop:bg-transparent p-0 text-white"
105
+ onMouseDown={handleBackdropClick}
106
+ onTouchStart={handleTouchStart}
107
+ onTouchEnd={handleTouchEnd}
108
+ aria-label="Image lightbox"
109
+ >
110
+ {/* Top bar */}
111
+ <div class="absolute inset-x-0 top-0 z-10 flex items-start justify-between p-4 sm:p-6">
112
+ <div class="flex min-w-0 flex-col gap-2">
113
+ <Show when={isMultiple()}>
114
+ <span class="inline-flex w-fit items-center rounded-full border border-white/12 bg-white/8 px-3 py-1 text-xs text-white/80 backdrop-blur" aria-live="polite">
115
+ {index() + 1} / {props.images.length}
116
+ </span>
117
+ </Show>
118
+ <Show when={current()?.alt}>
119
+ <div class="max-w-[min(70vw,42rem)] rounded-2xl border border-white/10 bg-black/30 px-4 py-2 backdrop-blur-sm">
120
+ <div class="truncate text-sm font-medium text-white">{current()?.alt}</div>
121
+ </div>
122
+ </Show>
123
+ </div>
124
+
125
+ <div class="flex items-center gap-2">
126
+ <Show when={current()?.downloadUrl}>
127
+ <a
128
+ href={current()!.downloadUrl}
129
+ download=""
130
+ class="inline-flex h-11 items-center gap-2 rounded-full border border-white/12 bg-white/8 px-4 text-sm text-white transition-colors hover:bg-white/14"
131
+ aria-label="Download image"
132
+ >
133
+ <i class="ti ti-download text-base" />
134
+ <span class="hidden sm:inline">Download</span>
135
+ </a>
136
+ </Show>
137
+ <button
138
+ type="button"
139
+ onClick={close}
140
+ class="inline-flex h-11 items-center gap-2 rounded-full border border-white/12 bg-white/8 px-4 text-sm text-white transition-colors hover:bg-white/14"
141
+ aria-label="Close lightbox"
142
+ >
143
+ <i class="ti ti-x text-base" />
144
+ <span class="hidden sm:inline">Close</span>
145
+ </button>
146
+ </div>
147
+ </div>
148
+
149
+ <div class="flex h-full w-full items-center justify-center px-6 pb-20 pt-24 sm:px-12 sm:pb-24 sm:pt-28">
150
+ <img src={current()?.src} alt={current()?.alt ?? ""} class="max-h-full max-w-full object-contain select-none" draggable={false} />
151
+ </div>
152
+
153
+ <Show when={isMultiple()}>
154
+ <button
155
+ type="button"
156
+ onClick={prev}
157
+ class="absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-white/8 text-white transition-colors hover:bg-white/14 sm:left-6"
158
+ aria-label="Previous image"
159
+ >
160
+ <i class="ti ti-chevron-left text-2xl" />
161
+ </button>
162
+ <button
163
+ type="button"
164
+ onClick={next}
165
+ class="absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-white/8 text-white transition-colors hover:bg-white/14 sm:right-6"
166
+ aria-label="Next image"
167
+ >
168
+ <i class="ti ti-chevron-right text-2xl" />
169
+ </button>
170
+ </Show>
171
+
172
+ <Show when={isMultiple() && props.images.length <= 10}>
173
+ <div class="absolute bottom-5 left-1/2 flex -translate-x-1/2 gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-2 backdrop-blur" role="tablist" aria-label="Image navigation">
174
+ <For each={props.images}>
175
+ {(_, i) => (
176
+ <button
177
+ type="button"
178
+ onClick={() => setIndex(i())}
179
+ class="h-2.5 w-2.5 rounded-full transition-colors"
180
+ classList={{
181
+ "bg-white": index() === i(),
182
+ "bg-white/40 hover:bg-white/60": index() !== i(),
183
+ }}
184
+ role="tab"
185
+ aria-selected={index() === i()}
186
+ aria-label={`Go to image ${i() + 1}`}
187
+ />
188
+ )}
189
+ </For>
190
+ </div>
191
+ </Show>
192
+ </dialog>
193
+ );
194
+ }
@@ -0,0 +1,34 @@
1
+ type LinkCardProps = {
2
+ href: string;
3
+ title: string;
4
+ description: string;
5
+ icon: string;
6
+ color: "blue" | "emerald" | "violet" | "orange" | "red" | "amber" | "zinc" | "cyan" | "rose";
7
+ };
8
+
9
+ const colorClasses = {
10
+ blue: "bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400",
11
+ emerald: "bg-emerald-100 dark:bg-emerald-900/50 text-emerald-600 dark:text-emerald-400",
12
+ violet: "bg-violet-100 dark:bg-violet-900/50 text-violet-600 dark:text-violet-400",
13
+ orange: "bg-orange-100 dark:bg-orange-900/50 text-orange-600 dark:text-orange-400",
14
+ red: "bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400",
15
+ amber: "bg-amber-100 dark:bg-amber-900/50 text-amber-600 dark:text-amber-400",
16
+ zinc: "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400",
17
+ cyan: "bg-cyan-100 dark:bg-cyan-900/50 text-cyan-600 dark:text-cyan-400",
18
+ rose: "bg-rose-100 dark:bg-rose-900/50 text-rose-600 dark:text-rose-400",
19
+ };
20
+
21
+ export default function LinkCard(props: LinkCardProps) {
22
+ return (
23
+ <a href={props.href} class="paper p-4 flex items-center gap-4 hover:paper-highlighted transition-all">
24
+ <div class={`flex items-center justify-center h-10 w-10 shrink-0 rounded ${colorClasses[props.color]}`}>
25
+ <i class={`${props.icon} text-xl`} />
26
+ </div>
27
+ <div class="flex-1 min-w-0">
28
+ <span class="text-sm font-semibold text-primary block">{props.title}</span>
29
+ <p class="text-xs text-dimmed truncate">{props.description}</p>
30
+ </div>
31
+ <i class="ti ti-chevron-right text-dimmed" />
32
+ </a>
33
+ );
34
+ }
@@ -0,0 +1,61 @@
1
+ import { dates } from "@valentinkolb/stdlib";
2
+
3
+ export type LogTableEntry = {
4
+ id: number | string;
5
+ level: string;
6
+ source: string;
7
+ message: string;
8
+ metadata: Record<string, unknown> | null;
9
+ createdAt: string;
10
+ };
11
+
12
+ type Props = {
13
+ entries: LogTableEntry[];
14
+ emptyMessage?: string;
15
+ };
16
+
17
+ const levelIcon: Record<string, { icon: string; color: string; label: string }> = {
18
+ debug: { icon: "ti ti-bug", color: "text-zinc-400 dark:text-zinc-500", label: "debug" },
19
+ info: { icon: "ti ti-info-circle", color: "text-blue-500 dark:text-blue-400", label: "info" },
20
+ warn: { icon: "ti ti-alert-triangle", color: "text-amber-500 dark:text-amber-400", label: "warn" },
21
+ error: { icon: "ti ti-alert-circle", color: "text-red-500 dark:text-red-400", label: "error" },
22
+ };
23
+
24
+ export default function LogEntriesTable(props: Props) {
25
+ if (props.entries.length === 0) {
26
+ return <div class="py-8 text-center text-xs text-dimmed">{props.emptyMessage ?? "No log entries found."}</div>;
27
+ }
28
+
29
+ return (
30
+ <div class="overflow-x-auto">
31
+ <table class="w-full text-xs">
32
+ <thead>
33
+ <tr class="border-b border-zinc-100 dark:border-zinc-800">
34
+ <th class="px-3 py-2 text-left font-medium text-dimmed">Level</th>
35
+ <th class="px-3 py-2 text-left font-medium text-dimmed">Source ({props.entries.length})</th>
36
+ <th class="px-3 py-2 text-left font-medium text-dimmed">Message</th>
37
+ <th class="px-3 py-2 text-left font-medium text-dimmed">Time</th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ {props.entries.map((entry) => {
42
+ const level = levelIcon[entry.level] ?? levelIcon.debug!;
43
+ return (
44
+ <tr class="border-b border-zinc-50 last:border-0 hover:bg-zinc-50 dark:border-zinc-800/50 dark:hover:bg-zinc-800/30">
45
+ <td class="px-3 py-1.5 whitespace-nowrap">
46
+ <span class={`inline-flex items-center gap-1.5 ${level.color}`}>
47
+ <i class={`${level.icon} text-sm`} />
48
+ <span>{level.label}</span>
49
+ </span>
50
+ </td>
51
+ <td class="px-3 py-1.5 whitespace-nowrap text-secondary">{entry.source}</td>
52
+ <td class="px-3 py-1.5 text-primary truncate max-w-[30rem]" title={entry.message}>{entry.message}</td>
53
+ <td class="whitespace-nowrap px-3 py-1.5 text-dimmed">{dates.formatDateTime(entry.createdAt)}</td>
54
+ </tr>
55
+ );
56
+ })}
57
+ </tbody>
58
+ </table>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,65 @@
1
+ type Props = {
2
+ /** Pre-rendered HTML from server-side renderMarkdown() */
3
+ html: string;
4
+ /** Optional additional CSS classes */
5
+ class?: string;
6
+ /**
7
+ * Reduce heading sizes for compact contexts like comments.
8
+ * When true, h1-h6 are all rendered at similar small sizes.
9
+ */
10
+ smallHeadings?: boolean;
11
+ };
12
+
13
+ /**
14
+ * Markdown View Component (SSR)
15
+ *
16
+ * Displays pre-rendered markdown HTML with prose styling.
17
+ * This component does not set a max-width - the parent should control that.
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * // Server-side (in page.tsx):
22
+ * import { renderMarkdown } from "@/shared/markdown";
23
+ * const html = renderMarkdown(markdownContent);
24
+ *
25
+ * // In your component:
26
+ * import MarkdownView from "@/ui/misc/MarkdownView";
27
+ * <div class="max-w-4xl mx-auto">
28
+ * <MarkdownView html={html} />
29
+ * </div>
30
+ * ```
31
+ *
32
+ * @example With Mermaid & KaTeX support (in an island component):
33
+ * ```tsx
34
+ * import { onMount } from "solid-js";
35
+ * import MarkdownView from "@/ui/misc/MarkdownView";
36
+ * import { initMarkdownEnhancements } from "@/shared/markdown/client";
37
+ *
38
+ * export default function MyComponent(props: { html: string }) {
39
+ * let containerRef: HTMLDivElement | undefined;
40
+ *
41
+ * onMount(() => {
42
+ * if (containerRef) {
43
+ * initMarkdownEnhancements(containerRef);
44
+ * }
45
+ * });
46
+ *
47
+ * return (
48
+ * <div ref={containerRef}>
49
+ * <MarkdownView html={props.html} />
50
+ * </div>
51
+ * );
52
+ * }
53
+ * ```
54
+ */
55
+ export default function MarkdownView(props: Props) {
56
+ const classes = () => {
57
+ const base = "prose prose-sm dark:prose-invert max-w-none";
58
+ const small = props.smallHeadings
59
+ ? "[&_h1]:!text-sm [&_h2]:!text-sm [&_h3]:!text-sm [&_h4]:!text-sm [&_h5]:!text-sm [&_h6]:!text-sm [&_h1]:!font-semibold [&_h2]:!font-semibold [&_h3]:!font-semibold [&_h4]:!font-semibold [&_h5]:!font-semibold [&_h6]:!font-semibold [&_h1]:!my-0 [&_h2]:!my-0 [&_h3]:!my-0 [&_h4]:!my-0 [&_h5]:!my-0 [&_h6]:!my-0 [&_h1]:!underline [&_h2]:!underline [&_h3]:!underline [&_h4]:!underline [&_h5]:!underline [&_h6]:!underline"
60
+ : "";
61
+ return `${base} ${small} ${props.class ?? ""}`;
62
+ };
63
+
64
+ return <div class={classes()} innerHTML={props.html} />;
65
+ }
@@ -0,0 +1,51 @@
1
+ import type { JSX } from "solid-js";
2
+
3
+ type PaginationProps = {
4
+ currentPage: number;
5
+ totalPages: number;
6
+ baseUrl: string;
7
+ };
8
+
9
+ /**
10
+ * Renders pagination controls with ellipsis for large page counts.
11
+ * Shows first, last, and pages adjacent to current page.
12
+ */
13
+ export const Pagination = (props: PaginationProps): null | JSX.Element => {
14
+ if (props.totalPages <= 1) return null;
15
+
16
+ const visiblePages = Array.from({ length: props.totalPages }, (_, i) => i + 1).filter(
17
+ (p) => p === 1 || p === props.totalPages || Math.abs(p - props.currentPage) <= 1,
18
+ );
19
+
20
+ return (
21
+ <nav class="flex items-center justify-center gap-0.5 pt-3" aria-label="Pagination">
22
+ {visiblePages.map((page, idx) => {
23
+ const prevPage = visiblePages[idx - 1];
24
+ const shouldShowEllipsis = prevPage && page - prevPage > 1;
25
+ const isActive = page === props.currentPage;
26
+
27
+ return (
28
+ <>
29
+ {shouldShowEllipsis && (
30
+ <span class="flex h-7 w-7 items-center justify-center text-dimmed text-xs select-none" aria-hidden="true">
31
+ ...
32
+ </span>
33
+ )}
34
+ <a
35
+ href={`${props.baseUrl}${page}`}
36
+ class={`flex h-7 w-7 items-center justify-center rounded-lg text-xs tabular-nums transition-colors ${
37
+ isActive
38
+ ? "border-blue-500/35 bg-blue-50 text-blue-700 font-medium dark:border-blue-400/40 dark:bg-blue-950/40 dark:text-blue-200"
39
+ : "text-dimmed hover:text-primary hover:bg-zinc-100 dark:hover:bg-zinc-800"
40
+ }`}
41
+ aria-current={isActive ? "page" : undefined}
42
+ aria-label={`Page ${page}`}
43
+ >
44
+ {page}
45
+ </a>
46
+ </>
47
+ );
48
+ })}
49
+ </nav>
50
+ );
51
+ };