@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,854 @@
1
+ /**
2
+ * Minimal dialog library for alert, confirm, prompt, and custom dialogs
3
+ * @module prompt-lib
4
+ */
5
+
6
+ import CheckboxInput from "./input/Checkbox";
7
+ import DateTimeInput from "./input/DateTimeInput";
8
+ import ImageInput from "./input/ImageInput";
9
+ import NumberInput from "./input/NumberInput";
10
+ import PinInput from "./input/PinInput";
11
+ import SelectInput from "./input/Select";
12
+ import TagsInput from "./input/TagsInput";
13
+ import TextInput from "./input/TextInput";
14
+ import { mutation, timed } from "@valentinkolb/stdlib/solid";
15
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js";
16
+ import { createStore } from "solid-js/store";
17
+ import { dialogCore } from "./dialog-core";
18
+
19
+ /**
20
+ * Configuration options for dialog appearance and behavior
21
+ */
22
+ export interface DialogOptions {
23
+ /** Optional title displayed in the dialog header */
24
+ title?: string;
25
+ /** Optional icon class for header (e.g., "ti ti-trash") */
26
+ icon?: string;
27
+ /** Custom text for the confirm/OK button*/
28
+ confirmText?: string;
29
+ /** Custom text for the cancel button, or false to hide it*/
30
+ cancelText?: string | false;
31
+ /** Visual variant affecting button and outline colors */
32
+ variant?: "danger" | "primary" | "success";
33
+ /** Dialog size preset (default: "medium") */
34
+ size?: "small" | "medium" | "large";
35
+ }
36
+
37
+ export type PromptSearchItem<T = unknown> = {
38
+ label: string;
39
+ desc?: string;
40
+ icon?: string;
41
+ previewUrl?: string;
42
+ value?: T;
43
+ onClick?: (item: PromptSearchItem<T>) => void | Promise<void>;
44
+ };
45
+
46
+ export type PromptSearchInput = {
47
+ query: string;
48
+ abortSignal: AbortSignal;
49
+ };
50
+
51
+ export type PromptSearchOptions = DialogOptions & {
52
+ placeholder?: string;
53
+ icon?: string;
54
+ initialQuery?: string;
55
+ minQueryLength?: number;
56
+ debounceMs?: number;
57
+ emptyText?: string;
58
+ noResultsText?: string;
59
+ };
60
+
61
+ /**
62
+ * Base field configuration shared by all field types
63
+ */
64
+ type BaseField<T = any> = {
65
+ label?: string | false;
66
+ description?: string;
67
+ placeholder?: string;
68
+ required?: boolean;
69
+ default?: T;
70
+ validate?: (value: T | undefined) => string | null;
71
+ };
72
+
73
+ /**
74
+ * Field schema for form inputs - discriminated union of all field types
75
+ */
76
+ export type FieldSchema =
77
+ | (BaseField<string> & {
78
+ type: "text";
79
+ multiline?: boolean;
80
+ maxLength?: number;
81
+ minLength?: number;
82
+ icon?: string;
83
+ activeIcon?: string;
84
+ password?: boolean;
85
+ })
86
+ | (BaseField<number> & {
87
+ type: "number";
88
+ min?: number;
89
+ max?: number;
90
+ step?: number;
91
+ })
92
+ | (BaseField<string> & {
93
+ type: "image";
94
+ round?: boolean;
95
+ ariaLabel?: string;
96
+ })
97
+ | (BaseField<string> & {
98
+ type: "pin";
99
+ length?: number;
100
+ stretch?: boolean;
101
+ })
102
+ | (BaseField<string> & {
103
+ type: "select";
104
+ options: string[] | { id: string; label?: string; description?: string; icon?: string }[];
105
+ icon?: string;
106
+ activeIcon?: string;
107
+ clearable?: boolean;
108
+ })
109
+ | (BaseField<string[]> & {
110
+ type: "tags";
111
+ maxTags?: number;
112
+ minTags?: number;
113
+ icon?: string;
114
+ activeIcon?: string;
115
+ })
116
+ | (BaseField<boolean> & {
117
+ type: "boolean";
118
+ })
119
+ | (BaseField<string> & {
120
+ type: "datetime";
121
+ /** Use date-only input instead of datetime-local */
122
+ dateOnly?: boolean;
123
+ })
124
+ | {
125
+ type: "info";
126
+ content: string | JSX.Element | (() => JSX.Element);
127
+ };
128
+
129
+ /**
130
+ * Extract value type from field schema
131
+ */
132
+ type InferFieldType<T extends FieldSchema> = T extends { type: "text" }
133
+ ? string
134
+ : T extends { type: "number" }
135
+ ? number
136
+ : T extends { type: "image" }
137
+ ? string
138
+ : T extends { type: "pin" }
139
+ ? string
140
+ : T extends { type: "select" }
141
+ ? string
142
+ : T extends { type: "tags" }
143
+ ? string[]
144
+ : T extends { type: "boolean" }
145
+ ? boolean
146
+ : T extends { type: "datetime" }
147
+ ? string
148
+ : T extends { type: "currency" }
149
+ ? number
150
+ : T extends { type: "info" }
151
+ ? never
152
+ : never;
153
+
154
+ /**
155
+ * Infer form values type from schema, excluding info fields
156
+ */
157
+ type InferFormValues<T extends Record<string, FieldSchema>> = {
158
+ [K in keyof T as T[K] extends { type: "info" } ? never : K]: T[K] extends {
159
+ required: true;
160
+ }
161
+ ? InferFieldType<T[K]>
162
+ : InferFieldType<T[K]> | undefined;
163
+ };
164
+
165
+ /**
166
+ * Reusable form state management hook
167
+ * @param schema - Form field schema
168
+ * @returns Form state utilities
169
+ */
170
+ export const createFormState = <T extends Record<string, FieldSchema>>(schema: T) => {
171
+ const [values, setValues] = createStore<any>({});
172
+ const [errors, setErrors] = createStore<Record<string, string>>({});
173
+
174
+ // Initialize with default values
175
+ Object.entries(schema).forEach(([key, field]) => {
176
+ if (field.type !== "info" && "default" in field) {
177
+ setValues(key, field.default);
178
+ }
179
+ });
180
+
181
+ // Validate single field
182
+ const validateField = (key: string, value: any): string | null => {
183
+ const field = schema[key];
184
+ if (!field || field.type === "info") return null;
185
+
186
+ // Required check
187
+ if (field.required && (value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0))) {
188
+ return "required";
189
+ }
190
+
191
+ // Custom validator
192
+ if ("validate" in field && field.validate) {
193
+ return field.validate(value);
194
+ }
195
+
196
+ return null;
197
+ };
198
+
199
+ // Update field value and validation
200
+ const updateField = (key: string, value: any) => {
201
+ setValues(key, value);
202
+ const error = validateField(key, value);
203
+ setErrors(key, error || (undefined as any));
204
+ };
205
+
206
+ // Validate all fields
207
+ const validateAll = (): boolean => {
208
+ let isValid = true;
209
+ Object.entries(schema).forEach(([key, field]) => {
210
+ if (field.type !== "info") {
211
+ const error = validateField(key, values[key]);
212
+ if (error) {
213
+ setErrors(key, error);
214
+ isValid = false;
215
+ } else {
216
+ setErrors(key, undefined as any);
217
+ }
218
+ }
219
+ });
220
+ return isValid;
221
+ };
222
+
223
+ // Reset to initial state
224
+ const reset = () => {
225
+ Object.entries(schema).forEach(([key, field]) => {
226
+ if (field.type !== "info") {
227
+ setValues(key, "default" in field ? field.default : undefined);
228
+ setErrors(key, undefined as any);
229
+ }
230
+ });
231
+ };
232
+
233
+ return {
234
+ values,
235
+ errors,
236
+ updateField,
237
+ validateAll,
238
+ reset,
239
+ };
240
+ };
241
+
242
+ export const DialogHeader = (props: { close: () => void; title?: string; icon?: string }) => {
243
+ const { title, icon, close } = props || {};
244
+ return (
245
+ <div class="flex flex-row items-center justify-start gap-4 border-b border-zinc-200 pb-2 dark:border-zinc-700">
246
+ {icon && <i class={`${icon}`} />}
247
+ {title && <p class="truncate font-semibold">{title}</p>}
248
+ <button type="button" onClick={() => close()} class="ti ti-x ml-auto" aria-label="close dialog" />
249
+ </div>
250
+ );
251
+ };
252
+
253
+ const getSizeClassName = (size: DialogOptions["size"] = "medium") => {
254
+ if (size === "small") return "w-[min(90vw,22rem)] max-h-[72vh]";
255
+ if (size === "large") return "w-[min(96vw,48rem)] max-h-[86vh]";
256
+ return "w-[min(94vw,28rem)] max-h-[90vh]";
257
+ };
258
+
259
+ const getVariantClassName = (variant?: DialogOptions["variant"]) => {
260
+ if (variant === "danger") return "ring-red-500/45 dark:ring-red-500/35";
261
+ if (variant === "success") return "ring-green-500/45 dark:ring-green-500/35";
262
+ return "ring-zinc-300/60 dark:ring-zinc-700/60";
263
+ };
264
+
265
+ const getPanelClassName = (options?: Pick<DialogOptions, "variant" | "size">) => {
266
+ const sizeClass = getSizeClassName(options?.size);
267
+ const variantClass = getVariantClassName(options?.variant);
268
+ return `fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 m-0 ${sizeClass} overflow-x-hidden overflow-y-auto rounded-2xl border-0 bg-white/95 p-4 text-zinc-900 shadow-none ring-1 ring-inset ${variantClass} backdrop:bg-black/45 dark:backdrop:bg-black/35 backdrop:backdrop-blur-sm dark:bg-zinc-950/95 dark:text-zinc-100`;
269
+ };
270
+
271
+ const getSearchPanelClassName = () =>
272
+ "fixed left-1/2 top-[25vh] -translate-x-1/2 m-0 w-[min(96vw,46rem)] h-[50vh] border-0 bg-transparent p-0 text-zinc-900 shadow-none backdrop:bg-black/45 dark:backdrop:bg-black/35 backdrop:backdrop-blur-sm dark:text-zinc-100 [@media(min-height:1100px)]:top-[33vh] [@media(min-height:1100px)]:h-[33vh]";
273
+
274
+ const isPreviewUrl = (value?: string) => typeof value === "string" && value.startsWith("/");
275
+
276
+ const openSearchPrompt = <T = unknown>(
277
+ resolver: (input: PromptSearchInput) => Promise<PromptSearchItem<T>[]> | PromptSearchItem<T>[],
278
+ options?: PromptSearchOptions,
279
+ ) =>
280
+ dialogCore.open<PromptSearchItem<T>>((close) => {
281
+ const [query, setQuery] = createSignal(options?.initialQuery ?? "");
282
+ const [items, setItems] = createSignal<PromptSearchItem<T>[]>([]);
283
+ const [activeIndex, setActiveIndex] = createSignal(0);
284
+ const [hasLoaded, setHasLoaded] = createSignal(false);
285
+ const [failedPreviews, setFailedPreviews] = createStore<Record<number, true>>({});
286
+ const [activeSearchQuery, setActiveSearchQuery] = createSignal("");
287
+
288
+ const rowRefs = new Map<number, HTMLButtonElement>();
289
+ let inputRef: HTMLInputElement | undefined;
290
+
291
+ const minQueryLength = options?.minQueryLength ?? 0;
292
+ const debounceMs = options?.debounceMs ?? 180;
293
+ const searchMutation = mutation.create<
294
+ {
295
+ query: string;
296
+ items: PromptSearchItem<T>[];
297
+ },
298
+ string,
299
+ { requestQuery: string }
300
+ >({
301
+ onBefore: (requestQuery) => ({ requestQuery }),
302
+ mutation: async (requestQuery, ctx) => {
303
+ const result = await resolver({
304
+ query: requestQuery,
305
+ abortSignal: ctx.abortSignal,
306
+ });
307
+ return { query: requestQuery, items: (result ?? []).slice() };
308
+ },
309
+ onSuccess: (result, ctx) => {
310
+ if (!ctx || ctx.requestQuery !== activeSearchQuery()) return;
311
+ setItems(result.items);
312
+ setActiveIndex(0);
313
+ setHasLoaded(true);
314
+ },
315
+ onError: (err, ctx) => {
316
+ if (!ctx || ctx.requestQuery !== activeSearchQuery()) return;
317
+ if (err.name === "AbortError") return;
318
+ setItems([]);
319
+ setActiveIndex(0);
320
+ setHasLoaded(true);
321
+ },
322
+ });
323
+ const searchError = createMemo(() => {
324
+ const err = searchMutation.error();
325
+ if (!err || err.name === "AbortError") return null;
326
+ return err.message || "Search failed.";
327
+ });
328
+ const shouldShowResults = createMemo(() => {
329
+ if (query().trim().length < minQueryLength) return false;
330
+ return hasLoaded() || searchError() !== null || items().length > 0;
331
+ });
332
+ const emptyStateText = createMemo(() => {
333
+ if (!hasLoaded()) return options?.emptyText ?? "Type to search.";
334
+ return options?.noResultsText ?? "No results.";
335
+ });
336
+ const getItemClassName = (isActive: boolean) =>
337
+ `flex w-full items-start gap-2.5 rounded-lg px-2 py-2 text-left transition-colors ${
338
+ isActive
339
+ ? "bg-blue-50/80 text-blue-900 dark:bg-blue-950/45 dark:text-blue-100"
340
+ : "hover:bg-zinc-200/65 dark:hover:bg-zinc-800/70"
341
+ }`;
342
+ const { debouncedFn: debounceSearch, cancel: cancelDebounce } = timed.debounce((nextQuery: string) => {
343
+ setActiveSearchQuery(nextQuery);
344
+ searchMutation.abort();
345
+ void searchMutation.mutate(nextQuery);
346
+ }, debounceMs);
347
+
348
+ const execute = async (item?: PromptSearchItem<T>) => {
349
+ if (!item) return;
350
+ if (item.onClick) await item.onClick(item);
351
+ close(item);
352
+ };
353
+
354
+ const moveSelection = (delta: -1 | 1) => {
355
+ const list = items();
356
+ if (list.length === 0) return;
357
+ const next = (activeIndex() + delta + list.length) % list.length;
358
+ setActiveIndex(next);
359
+ };
360
+
361
+ createEffect(() => {
362
+ const list = items();
363
+ const maxIndex = list.length - 1;
364
+ if (maxIndex < 0) {
365
+ setActiveIndex(0);
366
+ return;
367
+ }
368
+ if (activeIndex() > maxIndex) setActiveIndex(maxIndex);
369
+ rowRefs.get(activeIndex())?.scrollIntoView({ block: "nearest" });
370
+ });
371
+
372
+ createEffect(() => {
373
+ const nextQuery = query().trim();
374
+ setFailedPreviews({});
375
+
376
+ if (nextQuery.length < minQueryLength) {
377
+ cancelDebounce();
378
+ searchMutation.abort();
379
+ setItems([]);
380
+ setActiveIndex(0);
381
+ setHasLoaded(false);
382
+ setActiveSearchQuery("");
383
+ return;
384
+ }
385
+
386
+ debounceSearch(nextQuery);
387
+ });
388
+
389
+ onCleanup(() => {
390
+ cancelDebounce();
391
+ searchMutation.abort();
392
+ });
393
+
394
+ return (
395
+ <div class="flex h-full min-h-0 flex-col gap-2 pb-1 [--search-body-max:calc(50vh-3.5rem)] [@media(min-height:1100px)]:[--search-body-max:calc(33vh-3.5rem)]">
396
+ <Show when={options?.title}>
397
+ {(title) => (
398
+ <p class="px-1 text-base font-semibold text-white dark:text-zinc-100">
399
+ {title()}
400
+ </p>
401
+ )}
402
+ </Show>
403
+
404
+ <div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-white/95 text-zinc-900 shadow-none ring-1 ring-inset ring-zinc-300/60 dark:bg-zinc-950/95 dark:text-zinc-100 dark:ring-zinc-700/60">
405
+ <label class="flex items-center gap-2 px-3 py-2.5">
406
+ <i class={`${options?.icon ?? "ti ti-search"} text-dimmed`} />
407
+ <input
408
+ ref={inputRef}
409
+ type="search"
410
+ value={query()}
411
+ onInput={(event) => setQuery(event.currentTarget.value)}
412
+ onKeyDown={(event) => {
413
+ if (event.key === "ArrowDown") {
414
+ event.preventDefault();
415
+ moveSelection(1);
416
+ return;
417
+ }
418
+ if (event.key === "ArrowUp") {
419
+ event.preventDefault();
420
+ moveSelection(-1);
421
+ return;
422
+ }
423
+ if (event.key === "Enter") {
424
+ event.preventDefault();
425
+ void execute(items()[activeIndex()]);
426
+ }
427
+ }}
428
+ placeholder={options?.placeholder ?? "Search..."}
429
+ class="w-full border-0 bg-transparent text-sm outline-none placeholder:text-dimmed"
430
+ spellcheck={false}
431
+ autocapitalize="off"
432
+ autocomplete="off"
433
+ autocorrect="off"
434
+ />
435
+ <Show when={searchMutation.loading()}>
436
+ <i class="ti ti-loader-2 animate-spin text-dimmed" />
437
+ </Show>
438
+ </label>
439
+
440
+ <div
441
+ class="overflow-hidden transition-[height,opacity] duration-200 ease-out"
442
+ style={{
443
+ height: shouldShowResults() ? "var(--search-body-max)" : "0px",
444
+ opacity: shouldShowResults() ? "1" : "0",
445
+ }}
446
+ >
447
+ <div class="h-full min-h-0 overflow-y-auto overscroll-y-contain px-2 pb-2" onWheel={(event) => event.stopPropagation()}>
448
+ <Show when={searchError()}>{(message) => <div class="info-block-danger mb-2 text-xs">{message()}</div>}</Show>
449
+
450
+ <Show when={items().length > 0} fallback={<p class="px-1.5 py-2 text-xs text-dimmed">{emptyStateText()}</p>}>
451
+ <div class="flex flex-col gap-1">
452
+ <For each={items()}>
453
+ {(item, index) => (
454
+ <button
455
+ ref={(element) => {
456
+ if (!element) {
457
+ rowRefs.delete(index());
458
+ return;
459
+ }
460
+ rowRefs.set(index(), element);
461
+ }}
462
+ type="button"
463
+ onMouseEnter={() => setActiveIndex(index())}
464
+ onClick={() => void execute(item)}
465
+ class={getItemClassName(activeIndex() === index())}
466
+ >
467
+ <Show when={isPreviewUrl(item.previewUrl) || item.icon}>
468
+ <span class="mt-0.5 grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-md bg-zinc-200/80 dark:bg-zinc-800/80">
469
+ <Show
470
+ when={isPreviewUrl(item.previewUrl) && !failedPreviews[index()]}
471
+ fallback={<i class={`${item.icon ?? "ti ti-file"} text-xs text-dimmed`} />}
472
+ >
473
+ <img
474
+ src={item.previewUrl}
475
+ alt={item.label}
476
+ class="h-full w-full object-cover"
477
+ onError={() => setFailedPreviews(index(), true)}
478
+ />
479
+ </Show>
480
+ </span>
481
+ </Show>
482
+
483
+ <div class="min-w-0 flex-1">
484
+ <p class="truncate text-sm leading-5">{item.label}</p>
485
+ <Show when={item.desc}>
486
+ <p class="mt-0.5 truncate text-xs leading-4 text-dimmed">{item.desc}</p>
487
+ </Show>
488
+ </div>
489
+ </button>
490
+ )}
491
+ </For>
492
+ </div>
493
+ </Show>
494
+ </div>
495
+ </div>
496
+ </div>
497
+ </div>
498
+ );
499
+ }, {
500
+ panelClassName: getSearchPanelClassName(),
501
+ contentClassName: "h-full min-h-0 p-0",
502
+ });
503
+
504
+ /**
505
+ * Simple dialog utilities for user interactions
506
+ *
507
+ * @example
508
+ * ```typescript
509
+ * // Simple alert
510
+ * await prompts.alert("File saved!");
511
+ *
512
+ * // Confirmation dialog
513
+ * const confirmed = await prompts.confirm("Delete this item?");
514
+ *
515
+ * // Text input
516
+ * const name = await prompts.prompt("Enter your name:");
517
+ *
518
+ * // Number input
519
+ * const age = await prompts.promptNumber("Enter your age:", 25);
520
+ *
521
+ * // Dynamic form with schema
522
+ * const values = await prompts.form({
523
+ * title: 'User Registration',
524
+ * icon: 'ti ti-user-plus',
525
+ * fields: {
526
+ * name: { type: 'text', required: true },
527
+ * age: { type: 'number', min: 18 },
528
+ * country: { type: 'select', options: ['DE', 'AT', 'CH'] },
529
+ * interests: { type: 'tags' },
530
+ * avatar: { type: 'image', round: true },
531
+ * price: { type: 'currency', min: 100 },
532
+ * pin: { type: 'pin', length: 4 },
533
+ * agree: { type: 'boolean', label: 'I agree to terms', required: true }
534
+ * }
535
+ * });
536
+ *
537
+ * // Custom dialog with SolidJS component
538
+ * const result = await prompts.dialog<boolean>((close) => (
539
+ * <div>
540
+ * <p>Custom content here</p>
541
+ * <button onClick={() => close(true)}>OK</button>
542
+ * </div>
543
+ * ));
544
+ *
545
+ * // Error dialog with danger variant
546
+ * await prompts.error("Something went wrong!");
547
+ * ```
548
+ */
549
+ export const prompts = {
550
+ /**
551
+ * Display an alert dialog with a single OK button
552
+ * @param content - Message to display (supports HTML)
553
+ * @param options - Optional styling and text configuration
554
+ * @returns Promise that resolves when dialog is closed
555
+ */
556
+ alert: (content: string | HTMLElement | JSX.Element, options?: DialogOptions) =>
557
+ dialogCore.open(
558
+ (close) => (
559
+ <div>
560
+ <DialogHeader title={options?.title || "Info"} icon={options?.icon} close={close} />
561
+
562
+ <div class="font-xs py-4 text-sm whitespace-pre-wrap">{content}</div>
563
+
564
+ <div class="flex justify-end gap-3">
565
+ <button
566
+ onClick={() => close()}
567
+ class={`${
568
+ options?.variant === "danger" ? "btn-danger" : options?.variant === "success" ? "btn-success" : "btn-primary"
569
+ } btn-sm`}
570
+ >
571
+ {options?.confirmText || "OK"}
572
+ </button>
573
+ </div>
574
+ </div>
575
+ ),
576
+ {
577
+ panelClassName: getPanelClassName(options),
578
+ },
579
+ ),
580
+
581
+ /**
582
+ * Display a success dialog with a single OK button.
583
+ */
584
+ success: (content: string | HTMLElement | JSX.Element, options?: Omit<DialogOptions, "variant">) =>
585
+ prompts.alert(content, {
586
+ ...options,
587
+ variant: "success",
588
+ title: options?.title ?? "Success",
589
+ icon: options?.icon ?? "ti ti-check",
590
+ }),
591
+
592
+ /**
593
+ * Display a confirmation dialog with OK and Cancel buttons
594
+ * @param content - Question/message to display
595
+ * @param options - Optional styling and text configuration
596
+ * @returns Promise resolving to true if confirmed, false if cancelled
597
+ */
598
+ confirm: (content: string | HTMLElement | JSX.Element, options?: DialogOptions) =>
599
+ dialogCore.open<boolean>(
600
+ (close) => (
601
+ <div>
602
+ <DialogHeader title={options?.title} icon={options?.icon} close={() => close(false)} />
603
+
604
+ <div class="font-xs py-4 text-sm whitespace-pre-wrap">{content}</div>
605
+
606
+ <div class="flex justify-end gap-3">
607
+ <button type="button" onClick={() => close(false)} class="btn-secondary btn-sm">
608
+ {options?.cancelText || "Nope"}
609
+ </button>
610
+ <button
611
+ type="button"
612
+ onClick={() => close(true)}
613
+ class={`${
614
+ options?.variant === "danger" ? "btn-danger" : options?.variant === "success" ? "btn-success" : "btn-primary"
615
+ } btn-sm`}
616
+ >
617
+ {options?.confirmText || "Yees"}
618
+ </button>
619
+ </div>
620
+ </div>
621
+ ),
622
+ {
623
+ panelClassName: getPanelClassName(options),
624
+ },
625
+ ),
626
+
627
+ /**
628
+ * Display a prompt dialog with text input
629
+ * @param content - Prompt message
630
+ * @param defaultValue - Initial value for the input field
631
+ * @param options - Optional styling and text configuration
632
+ * @returns Promise resolving to entered text (empty string is possible), or null if dialog was cancelled
633
+ */
634
+ prompt: (content: string, defaultValue?: string, options?: DialogOptions) =>
635
+ prompts
636
+ .form({
637
+ ...options,
638
+ fields: {
639
+ message: {
640
+ type: "info",
641
+ content: () => <div class="font-xs text-sm">{content}</div>,
642
+ },
643
+ value: {
644
+ type: "text",
645
+ label: false,
646
+ default: defaultValue || "",
647
+ },
648
+ },
649
+ })
650
+ .then((result) => result?.value ?? null),
651
+
652
+ /**
653
+ * Display a prompt dialog with number input
654
+ * @param content - Prompt message
655
+ * @param defaultValue - Initial value for the input field
656
+ * @param options - Optional styling and text configuration
657
+ * @returns Promise resolving to entered number, or null if cancelled/empty
658
+ */
659
+ promptNumber: async (
660
+ content: string,
661
+ defaultValue?: number,
662
+ options?: DialogOptions & {
663
+ min?: number;
664
+ max?: number;
665
+ },
666
+ ) =>
667
+ prompts
668
+ .form({
669
+ ...options,
670
+ fields: {
671
+ message: {
672
+ type: "info",
673
+ content: () => <div class="font-xs text-sm">{content}</div>,
674
+ },
675
+ value: {
676
+ type: "number",
677
+ label: false,
678
+ default: defaultValue || 0,
679
+ min: options?.min,
680
+ max: options?.max,
681
+ },
682
+ },
683
+ })
684
+ .then((result) => result?.value ?? null),
685
+
686
+ /**
687
+ * Build and display a dynamic form from schema
688
+ * @param config - Form configuration with title, icon, and fields
689
+ * @returns Promise resolving to form values or null if cancelled
690
+ *
691
+ * @example
692
+ * ```typescript
693
+ * const values = await prompts.form({
694
+ * title: 'User Form',
695
+ * icon: 'ti ti-user',
696
+ * fields: {
697
+ * name: { type: 'text', required: true },
698
+ * age: { type: 'number', min: 18 }
699
+ * }
700
+ * });
701
+ * ```
702
+ */
703
+ form: <T extends Record<string, FieldSchema>>(config: {
704
+ title?: string;
705
+ icon?: string;
706
+ fields: T;
707
+ confirmText?: string;
708
+ cancelText?: string | false;
709
+ variant?: "danger" | "primary" | "success";
710
+ size?: "small" | "medium" | "large";
711
+ }): Promise<InferFormValues<T> | null> => {
712
+ return dialogCore.open<InferFormValues<T> | null>((close) => {
713
+ const state = createFormState(config.fields);
714
+
715
+ // Field renderer map
716
+ const fieldRenderers: Record<string, (props: any, field: any) => JSX.Element> = {
717
+ text: (props, field) => (
718
+ <TextInput {...props} multiline={field.multiline} icon={field.icon} activeIcon={field.activeIcon} password={field.password} />
719
+ ),
720
+ number: (props, field) => <NumberInput {...props} min={field.min} max={field.max} step={field.step} />,
721
+ image: (props, field) => <ImageInput {...props} round={field.round} ariaLabel={field.ariaLabel} />,
722
+ pin: (props, field) => <PinInput {...props} length={field.length} stretch={field.stretch} />,
723
+ select: (props, field) => (
724
+ <SelectInput {...props} options={field.options} icon={field.icon} activeIcon={field.activeIcon} clearable={field.clearable} />
725
+ ),
726
+ tags: (props, field) => <TagsInput {...props} icon={field.icon} activeIcon={field.activeIcon} />,
727
+ boolean: (props) => <CheckboxInput {...props} />,
728
+ datetime: (props, field) => <DateTimeInput {...props} dateOnly={field.dateOnly} />,
729
+ };
730
+
731
+ // Handle form submission
732
+ const handleSubmit = (e: Event) => {
733
+ e.preventDefault();
734
+ if (state.validateAll()) {
735
+ close(state.values as InferFormValues<T>);
736
+ }
737
+ };
738
+
739
+ // Determine button variant class
740
+ const submitButtonClass = config.variant === "danger" ? "btn-danger" : config.variant === "success" ? "btn-success" : "btn-primary";
741
+
742
+ return (
743
+ <form onSubmit={handleSubmit} class="flex flex-col gap-4">
744
+ <DialogHeader title={config.title} icon={config.icon} close={() => close(null)} />
745
+
746
+ <div class="flex flex-col gap-4">
747
+ <For each={Object.entries(config.fields)}>
748
+ {([key, field]) => {
749
+ // Info field - just display content
750
+ if (field.type === "info") {
751
+ return (
752
+ <div>
753
+ {typeof field.content === "string" ? (
754
+ <p class="text-sm text-zinc-600 dark:text-zinc-400">{field.content}</p>
755
+ ) : typeof field.content === "function" ? (
756
+ field.content()
757
+ ) : (
758
+ field.content
759
+ )}
760
+ </div>
761
+ );
762
+ }
763
+
764
+ // Regular input fields
765
+ // Handle label: false or undefined means no label, otherwise use provided label
766
+ const label = field.label || undefined;
767
+ const commonProps = {
768
+ label,
769
+ description: field.description,
770
+ placeholder: field.placeholder,
771
+ required: field.required,
772
+ value: () => state.values[key],
773
+ onChange: (v: any) => state.updateField(key, v),
774
+ error: () => state.errors[key],
775
+ };
776
+
777
+ return fieldRenderers[field.type]?.(commonProps, field);
778
+ }}
779
+ </For>
780
+ </div>
781
+
782
+ <div class="flex justify-end gap-3">
783
+ <Show when={config.cancelText !== false}>
784
+ <button type="button" onClick={() => close(null)} class="btn-secondary btn-sm">
785
+ {config.cancelText || "ESC"}
786
+ </button>
787
+ </Show>
788
+ <button type="submit" class={`${submitButtonClass} btn-sm`}>
789
+ {config.confirmText || "ENTER"}
790
+ </button>
791
+ </div>
792
+ </form>
793
+ );
794
+ }, {
795
+ panelClassName: getPanelClassName(config),
796
+ }) as Promise<InferFormValues<T> | null>;
797
+ },
798
+
799
+ /**
800
+ * Display a custom dialog with a SolidJS component
801
+ * @param componentFactory - Function that receives close callback and returns JSX
802
+ * @param options - Optional dialog options for header (title, icon, variant)
803
+ * @returns Promise resolving to the result passed to close, or undefined if cancelled
804
+ *
805
+ * @example
806
+ * ```typescript
807
+ * const confirmed = await prompts.dialog<boolean>((close) => (
808
+ * <p class="mb-4">Custom content here</p>
809
+ * ), { title: "My Dialog", icon: "ti ti-info-circle" });
810
+ * ```
811
+ */
812
+ dialog: <T = any>(component: (close: (result?: T) => void) => JSX.Element, options?: DialogOptions) =>
813
+ dialogCore.open<T>(
814
+ (close: (result?: T) => void) => (
815
+ <div class="flex flex-col gap-4">
816
+ <DialogHeader title={options?.title} icon={options?.icon} close={() => close(undefined)} />
817
+ {component(close)}
818
+ </div>
819
+ ),
820
+ {
821
+ panelClassName: getPanelClassName(options),
822
+ },
823
+ ),
824
+
825
+ search: openSearchPrompt,
826
+
827
+ /**
828
+ * Wrapper around the alert dialog with error styling and icon
829
+ * @param content - Error message to display
830
+ * @param options - Optional styling and text configuration
831
+ * @returns Promise that resolves when dialog is closed
832
+ */
833
+ error: (content: string | HTMLElement, options?: DialogOptions) =>
834
+ dialogCore.open(
835
+ (close) => (
836
+ <div>
837
+ <DialogHeader title={options?.title ?? "Uuups"} icon={options?.icon ?? "ti ti-alert-circle"} close={close} />
838
+
839
+ <div class="font-xs p-4 text-sm">{content}</div>
840
+
841
+ <div class="flex justify-end gap-3">
842
+ <button onClick={() => close()} class="btn-primary btn-sm">
843
+ {options?.confirmText || "Ok .. me sad now"}
844
+ </button>
845
+ </div>
846
+ </div>
847
+ ),
848
+ {
849
+ panelClassName: getPanelClassName({ ...options, variant: "danger" }),
850
+ },
851
+ ),
852
+
853
+ getDialogElement: () => (typeof document === "undefined" ? undefined : document.querySelector<HTMLDialogElement>("dialog")),
854
+ };