@valentinkolb/cloud 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +116 -13
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/notifications/index.ts +82 -11
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +79 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +58 -0
  92. package/src/shared/redirect.ts +56 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -3,18 +3,18 @@
3
3
  * @module prompt-lib
4
4
  */
5
5
 
6
+ import { mutation, timed } from "@valentinkolb/stdlib/solid";
7
+ import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, Show } from "solid-js";
8
+ import { createStore } from "solid-js/store";
9
+ import { dialogCore } from "./dialog-core";
6
10
  import CheckboxInput from "./input/Checkbox";
7
- import DateTimeInput from "./input/DateTimeInput";
11
+ import { DatePicker, DateTimePicker } from "./input/DatePicker";
8
12
  import ImageInput from "./input/ImageInput";
9
13
  import NumberInput from "./input/NumberInput";
10
14
  import PinInput from "./input/PinInput";
11
15
  import SelectInput from "./input/Select";
12
16
  import TagsInput from "./input/TagsInput";
13
17
  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
18
 
19
19
  /**
20
20
  * Configuration options for dialog appearance and behavior
@@ -31,7 +31,11 @@ export interface DialogOptions {
31
31
  /** Visual variant affecting button and outline colors */
32
32
  variant?: "danger" | "primary" | "success";
33
33
  /** Dialog size preset (default: "medium") */
34
- size?: "small" | "medium" | "large";
34
+ size?: "small" | "medium" | "large" | "wide";
35
+ /** Dialog surface preset. "bare" keeps overlay/focus handling but leaves the visible panel to the content. */
36
+ surface?: "default" | "bare";
37
+ /** Hide the default title row. Mainly useful together with surface: "bare". */
38
+ header?: false;
35
39
  }
36
40
 
37
41
  export type PromptSearchItem<T = unknown> = {
@@ -77,11 +81,14 @@ export type FieldSchema =
77
81
  | (BaseField<string> & {
78
82
  type: "text";
79
83
  multiline?: boolean;
84
+ /** Approximate visible lines for multiline mode. Overrides default height. */
85
+ lines?: number;
80
86
  maxLength?: number;
81
87
  minLength?: number;
82
88
  icon?: string;
83
89
  activeIcon?: string;
84
90
  password?: boolean;
91
+ markdown?: boolean;
85
92
  })
86
93
  | (BaseField<number> & {
87
94
  type: "number";
@@ -241,8 +248,13 @@ export const createFormState = <T extends Record<string, FieldSchema>>(schema: T
241
248
 
242
249
  export const DialogHeader = (props: { close: () => void; title?: string; icon?: string }) => {
243
250
  const { title, icon, close } = props || {};
251
+ // Hierarchy comes from the font-semibold title + per-section
252
+ // padding. The previous `border-b border-zinc-200` violated the
253
+ // project's "no horizontal dividers between header/body/footer"
254
+ // rule. `pb-2` is kept so the title still has breathing room
255
+ // before whatever the body renders.
244
256
  return (
245
- <div class="flex flex-row items-center justify-start gap-4 border-b border-zinc-200 pb-2 dark:border-zinc-700">
257
+ <div class="flex flex-row items-center justify-start gap-4 pb-2">
246
258
  {icon && <i class={`${icon}`} />}
247
259
  {title && <p class="truncate font-semibold">{title}</p>}
248
260
  <button type="button" onClick={() => close()} class="ti ti-x ml-auto" aria-label="close dialog" />
@@ -252,6 +264,7 @@ export const DialogHeader = (props: { close: () => void; title?: string; icon?:
252
264
 
253
265
  const getSizeClassName = (size: DialogOptions["size"] = "medium") => {
254
266
  if (size === "small") return "w-[min(90vw,22rem)] max-h-[72vh]";
267
+ if (size === "wide") return "w-[min(96vw,64rem)] max-h-[90vh]";
255
268
  if (size === "large") return "w-[min(96vw,48rem)] max-h-[86vh]";
256
269
  return "w-[min(94vw,28rem)] max-h-[90vh]";
257
270
  };
@@ -262,12 +275,17 @@ const getVariantClassName = (variant?: DialogOptions["variant"]) => {
262
275
  return "ring-zinc-300/60 dark:ring-zinc-700/60";
263
276
  };
264
277
 
265
- const getPanelClassName = (options?: Pick<DialogOptions, "variant" | "size">) => {
278
+ const getPanelClassName = (options?: Pick<DialogOptions, "variant" | "size" | "surface">) => {
266
279
  const sizeClass = getSizeClassName(options?.size);
280
+ if (options?.surface === "bare") {
281
+ return `fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 m-0 ${sizeClass} overflow-visible 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`;
282
+ }
267
283
  const variantClass = getVariantClassName(options?.variant);
268
284
  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
285
  };
270
286
 
287
+ const getContentClassName = (surface?: DialogOptions["surface"]) => (surface === "bare" ? "" : undefined);
288
+
271
289
  const getSearchPanelClassName = () =>
272
290
  "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
291
 
@@ -277,229 +295,226 @@ const openSearchPrompt = <T = unknown>(
277
295
  resolver: (input: PromptSearchInput) => Promise<PromptSearchItem<T>[]> | PromptSearchItem<T>[],
278
296
  options?: PromptSearchOptions,
279
297
  ) =>
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
- };
298
+ dialogCore.open<PromptSearchItem<T>>(
299
+ (close) => {
300
+ const [query, setQuery] = createSignal(options?.initialQuery ?? "");
301
+ const [items, setItems] = createSignal<PromptSearchItem<T>[]>([]);
302
+ const [activeIndex, setActiveIndex] = createSignal(0);
303
+ const [hasLoaded, setHasLoaded] = createSignal(false);
304
+ const [failedPreviews, setFailedPreviews] = createStore<Record<number, true>>({});
305
+ const [activeSearchQuery, setActiveSearchQuery] = createSignal("");
306
+
307
+ const rowRefs = new Map<number, HTMLButtonElement>();
308
+ let inputRef: HTMLInputElement | undefined;
309
+
310
+ const minQueryLength = options?.minQueryLength ?? 0;
311
+ const debounceMs = options?.debounceMs ?? 180;
312
+ const searchMutation = mutation.create<
313
+ {
314
+ query: string;
315
+ items: PromptSearchItem<T>[];
316
+ },
317
+ string,
318
+ { requestQuery: string }
319
+ >({
320
+ onBefore: (requestQuery) => ({ requestQuery }),
321
+ mutation: async (requestQuery, ctx) => {
322
+ const result = await resolver({
323
+ query: requestQuery,
324
+ abortSignal: ctx.abortSignal,
325
+ });
326
+ return { query: requestQuery, items: (result ?? []).slice() };
327
+ },
328
+ onSuccess: (result, ctx) => {
329
+ if (!ctx || ctx.requestQuery !== activeSearchQuery()) return;
330
+ setItems(result.items);
331
+ setActiveIndex(0);
332
+ setHasLoaded(true);
333
+ },
334
+ onError: (err, ctx) => {
335
+ if (!ctx || ctx.requestQuery !== activeSearchQuery()) return;
336
+ if (err.name === "AbortError") return;
337
+ setItems([]);
338
+ setActiveIndex(0);
339
+ setHasLoaded(true);
340
+ },
341
+ });
342
+ const searchError = createMemo(() => {
343
+ const err = searchMutation.error();
344
+ if (!err || err.name === "AbortError") return null;
345
+ return err.message || "Search failed.";
346
+ });
347
+ const shouldShowResults = createMemo(() => {
348
+ if (query().trim().length < minQueryLength) return false;
349
+ return hasLoaded() || searchError() !== null || items().length > 0;
350
+ });
351
+ const emptyStateText = createMemo(() => {
352
+ if (!hasLoaded()) return options?.emptyText ?? "Type to search.";
353
+ return options?.noResultsText ?? "No results.";
354
+ });
355
+ const getItemClassName = (isActive: boolean) =>
356
+ `flex w-full items-start gap-2.5 rounded-lg px-2 py-2 text-left transition-colors ${
357
+ isActive ? "bg-blue-50/80 text-blue-900 dark:bg-blue-950/45 dark:text-blue-100" : "hover:bg-zinc-200/65 dark:hover:bg-zinc-800/70"
358
+ }`;
359
+ const { debouncedFn: debounceSearch, cancel: cancelDebounce } = timed.debounce((nextQuery: string) => {
360
+ setActiveSearchQuery(nextQuery);
361
+ searchMutation.abort();
362
+ void searchMutation.mutate(nextQuery);
363
+ }, debounceMs);
353
364
 
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
- };
365
+ const execute = async (item?: PromptSearchItem<T>) => {
366
+ if (!item) return;
367
+ if (item.onClick) await item.onClick(item);
368
+ close(item);
369
+ };
360
370
 
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
+ const moveSelection = (delta: -1 | 1) => {
372
+ const list = items();
373
+ if (list.length === 0) return;
374
+ const next = (activeIndex() + delta + list.length) % list.length;
375
+ setActiveIndex(next);
376
+ };
377
+
378
+ createEffect(() => {
379
+ const list = items();
380
+ const maxIndex = list.length - 1;
381
+ if (maxIndex < 0) {
382
+ setActiveIndex(0);
383
+ return;
384
+ }
385
+ if (activeIndex() > maxIndex) setActiveIndex(maxIndex);
386
+ rowRefs.get(activeIndex())?.scrollIntoView({ block: "nearest" });
387
+ });
388
+
389
+ createEffect(() => {
390
+ const nextQuery = query().trim();
391
+ setFailedPreviews({});
392
+
393
+ if (nextQuery.length < minQueryLength) {
394
+ cancelDebounce();
395
+ searchMutation.abort();
396
+ setItems([]);
397
+ setActiveIndex(0);
398
+ setHasLoaded(false);
399
+ setActiveSearchQuery("");
400
+ return;
401
+ }
371
402
 
372
- createEffect(() => {
373
- const nextQuery = query().trim();
374
- setFailedPreviews({});
403
+ debounceSearch(nextQuery);
404
+ });
375
405
 
376
- if (nextQuery.length < minQueryLength) {
406
+ onCleanup(() => {
377
407
  cancelDebounce();
378
408
  searchMutation.abort();
379
- setItems([]);
380
- setActiveIndex(0);
381
- setHasLoaded(false);
382
- setActiveSearchQuery("");
383
- return;
384
- }
385
-
386
- debounceSearch(nextQuery);
387
- });
409
+ });
388
410
 
389
- onCleanup(() => {
390
- cancelDebounce();
391
- searchMutation.abort();
392
- });
411
+ return (
412
+ <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)]">
413
+ <Show when={options?.title}>
414
+ {(title) => <p class="px-1 text-base font-semibold text-white dark:text-zinc-100">{title()}</p>}
415
+ </Show>
416
+
417
+ <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">
418
+ <label class="flex items-center gap-2 px-3 py-2.5">
419
+ <i class={`${options?.icon ?? "ti ti-search"} text-dimmed`} />
420
+ <input
421
+ ref={inputRef}
422
+ type="search"
423
+ value={query()}
424
+ onInput={(event) => setQuery(event.currentTarget.value)}
425
+ onKeyDown={(event) => {
426
+ if (event.key === "ArrowDown") {
427
+ event.preventDefault();
428
+ moveSelection(1);
429
+ return;
430
+ }
431
+ if (event.key === "ArrowUp") {
432
+ event.preventDefault();
433
+ moveSelection(-1);
434
+ return;
435
+ }
436
+ if (event.key === "Enter") {
437
+ event.preventDefault();
438
+ void execute(items()[activeIndex()]);
439
+ }
440
+ }}
441
+ placeholder={options?.placeholder ?? "Search..."}
442
+ class="w-full border-0 bg-transparent text-sm outline-none placeholder:text-dimmed"
443
+ spellcheck={false}
444
+ autocapitalize="off"
445
+ autocomplete="off"
446
+ autocorrect="off"
447
+ />
448
+ <Show when={searchMutation.loading()}>
449
+ <i class="ti ti-loader-2 animate-spin text-dimmed" />
450
+ </Show>
451
+ </label>
393
452
 
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
- }
453
+ <div
454
+ class="overflow-hidden transition-[height,opacity] duration-200 ease-out"
455
+ style={{
456
+ height: shouldShowResults() ? "var(--search-body-max)" : "0px",
457
+ opacity: shouldShowResults() ? "1" : "0",
427
458
  }}
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>
459
+ >
460
+ <div class="h-full min-h-0 overflow-y-auto overscroll-y-contain px-2 pb-2" onWheel={(event) => event.stopPropagation()}>
461
+ <Show when={searchError()}>{(message) => <div class="info-block-danger mb-2 text-xs">{message()}</div>}</Show>
462
+
463
+ <Show when={items().length > 0} fallback={<p class="px-1.5 py-2 text-xs text-dimmed">{emptyStateText()}</p>}>
464
+ <div class="flex flex-col gap-1">
465
+ <For each={items()}>
466
+ {(item, index) => (
467
+ <button
468
+ ref={(element) => {
469
+ if (!element) {
470
+ rowRefs.delete(index());
471
+ return;
472
+ }
473
+ rowRefs.set(index(), element);
474
+ }}
475
+ type="button"
476
+ onMouseEnter={() => setActiveIndex(index())}
477
+ onClick={() => void execute(item)}
478
+ class={getItemClassName(activeIndex() === index())}
479
+ >
480
+ <Show when={isPreviewUrl(item.previewUrl) || item.icon}>
481
+ <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">
482
+ <Show
483
+ when={isPreviewUrl(item.previewUrl) && !failedPreviews[index()]}
484
+ fallback={<i class={`${item.icon ?? "ti ti-file"} text-xs text-dimmed`} />}
485
+ >
486
+ <img
487
+ src={item.previewUrl}
488
+ alt={item.label}
489
+ class="h-full w-full object-cover"
490
+ onError={() => setFailedPreviews(index(), true)}
491
+ />
492
+ </Show>
493
+ </span>
487
494
  </Show>
488
- </div>
489
- </button>
490
- )}
491
- </For>
492
- </div>
493
- </Show>
495
+
496
+ <div class="min-w-0 flex-1">
497
+ <p class="truncate text-sm leading-5">{item.label}</p>
498
+ <Show when={item.desc}>
499
+ <p class="mt-0.5 truncate text-xs leading-4 text-dimmed">{item.desc}</p>
500
+ </Show>
501
+ </div>
502
+ </button>
503
+ )}
504
+ </For>
505
+ </div>
506
+ </Show>
507
+ </div>
494
508
  </div>
495
509
  </div>
496
510
  </div>
497
- </div>
498
- );
499
- }, {
500
- panelClassName: getSearchPanelClassName(),
501
- contentClassName: "h-full min-h-0 p-0",
502
- });
511
+ );
512
+ },
513
+ {
514
+ panelClassName: getSearchPanelClassName(),
515
+ contentClassName: "h-full min-h-0 p-0",
516
+ },
517
+ );
503
518
 
504
519
  /**
505
520
  * Simple dialog utilities for user interactions
@@ -575,6 +590,7 @@ export const prompts = {
575
590
  ),
576
591
  {
577
592
  panelClassName: getPanelClassName(options),
593
+ contentClassName: getContentClassName(options?.surface),
578
594
  },
579
595
  ),
580
596
 
@@ -605,7 +621,7 @@ export const prompts = {
605
621
 
606
622
  <div class="flex justify-end gap-3">
607
623
  <button type="button" onClick={() => close(false)} class="btn-secondary btn-sm">
608
- {options?.cancelText || "Nope"}
624
+ {options?.cancelText || "Cancel"}
609
625
  </button>
610
626
  <button
611
627
  type="button"
@@ -614,13 +630,14 @@ export const prompts = {
614
630
  options?.variant === "danger" ? "btn-danger" : options?.variant === "success" ? "btn-success" : "btn-primary"
615
631
  } btn-sm`}
616
632
  >
617
- {options?.confirmText || "Yees"}
633
+ {options?.confirmText || "Confirm"}
618
634
  </button>
619
635
  </div>
620
636
  </div>
621
637
  ),
622
638
  {
623
639
  panelClassName: getPanelClassName(options),
640
+ contentClassName: getContentClassName(options?.surface),
624
641
  },
625
642
  ),
626
643
 
@@ -707,93 +724,118 @@ export const prompts = {
707
724
  confirmText?: string;
708
725
  cancelText?: string | false;
709
726
  variant?: "danger" | "primary" | "success";
710
- size?: "small" | "medium" | "large";
727
+ size?: DialogOptions["size"];
711
728
  }): 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>
729
+ return dialogCore.open<InferFormValues<T> | null>(
730
+ (close) => {
731
+ const state = createFormState(config.fields);
732
+
733
+ // Field renderer map
734
+ const fieldRenderers: Record<string, (props: any, field: any) => JSX.Element> = {
735
+ text: (props, field) => (
736
+ <TextInput
737
+ {...props}
738
+ multiline={field.multiline}
739
+ lines={field.lines}
740
+ icon={field.icon}
741
+ activeIcon={field.activeIcon}
742
+ password={field.password}
743
+ markdown={field.markdown}
744
+ />
745
+ ),
746
+ number: (props, field) => <NumberInput {...props} min={field.min} max={field.max} step={field.step} />,
747
+ image: (props, field) => <ImageInput {...props} round={field.round} ariaLabel={field.ariaLabel} />,
748
+ pin: (props, field) => <PinInput {...props} length={field.length} stretch={field.stretch} />,
749
+ select: (props, field) => (
750
+ <SelectInput {...props} options={field.options} icon={field.icon} activeIcon={field.activeIcon} clearable={field.clearable} />
751
+ ),
752
+ tags: (props, field) => <TagsInput {...props} icon={field.icon} activeIcon={field.activeIcon} />,
753
+ boolean: (props) => <CheckboxInput {...props} />,
754
+ datetime: (props, field) => {
755
+ const pickerProps = {
756
+ ...props,
757
+ value: () => props.value?.() || null,
758
+ onChange: (value: string | null) => props.onChange?.(value ?? ""),
759
+ clearable: true,
760
+ };
761
+ return field.dateOnly ? <DatePicker {...pickerProps} /> : <DateTimePicker {...pickerProps} />;
762
+ },
763
+ };
764
+
765
+ const submit = () => {
766
+ if (state.validateAll()) {
767
+ close(state.values as InferFormValues<T>);
768
+ }
769
+ };
770
+
771
+ // Handle form submission
772
+ const handleSubmit = (e: Event) => {
773
+ e.preventDefault();
774
+ submit();
775
+ };
776
+
777
+ // Determine button variant class
778
+ const submitButtonClass = config.variant === "danger" ? "btn-danger" : config.variant === "success" ? "btn-success" : "btn-primary";
779
+
780
+ return (
781
+ <form onSubmit={handleSubmit} class="flex flex-col gap-4">
782
+ <DialogHeader title={config.title} icon={config.icon} close={() => close(null)} />
783
+
784
+ <div class="flex flex-col gap-4">
785
+ <For each={Object.entries(config.fields)}>
786
+ {([key, field]) => {
787
+ // Info field - just display content
788
+ if (field.type === "info") {
789
+ return (
790
+ <div>
791
+ {typeof field.content === "string" ? (
792
+ <p class="text-sm text-zinc-600 dark:text-zinc-400">{field.content}</p>
793
+ ) : typeof field.content === "function" ? (
794
+ field.content()
795
+ ) : (
796
+ field.content
797
+ )}
798
+ </div>
799
+ );
800
+ }
801
+
802
+ // Regular input fields
803
+ // Handle label: false or undefined means no label, otherwise use provided label
804
+ const label = field.label || undefined;
805
+ const commonProps = {
806
+ label,
807
+ description: field.description,
808
+ placeholder: field.placeholder,
809
+ required: field.required,
810
+ value: () => state.values[key],
811
+ onChange: (v: any) => state.updateField(key, v),
812
+ onSubmit: submit,
813
+ error: () => state.errors[key],
814
+ };
815
+
816
+ return fieldRenderers[field.type]?.(commonProps, field);
817
+ }}
818
+ </For>
819
+ </div>
781
820
 
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"}
821
+ <div class="flex justify-end gap-3">
822
+ <Show when={config.cancelText !== false}>
823
+ <button type="button" onClick={() => close(null)} class="btn-secondary btn-sm">
824
+ {config.cancelText || "Cancel"}
825
+ </button>
826
+ </Show>
827
+ <button type="submit" class={`${submitButtonClass} btn-sm`}>
828
+ {config.confirmText || "Save"}
786
829
  </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>;
830
+ </div>
831
+ </form>
832
+ );
833
+ },
834
+ {
835
+ panelClassName: getPanelClassName(config),
836
+ contentClassName: getContentClassName(),
837
+ },
838
+ ) as Promise<InferFormValues<T> | null>;
797
839
  },
798
840
 
799
841
  /**
@@ -811,14 +853,21 @@ export const prompts = {
811
853
  */
812
854
  dialog: <T = any>(component: (close: (result?: T) => void) => JSX.Element, options?: DialogOptions) =>
813
855
  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
- ),
856
+ (close: (result?: T) => void) => {
857
+ const body = component(close);
858
+ if (options?.surface === "bare" && options.header === false) return body;
859
+ return (
860
+ <div class="flex flex-col gap-4">
861
+ <Show when={options?.header !== false}>
862
+ <DialogHeader title={options?.title} icon={options?.icon} close={() => close(undefined)} />
863
+ </Show>
864
+ {body}
865
+ </div>
866
+ );
867
+ },
820
868
  {
821
869
  panelClassName: getPanelClassName(options),
870
+ contentClassName: getContentClassName(options?.surface),
822
871
  },
823
872
  ),
824
873
 
@@ -834,19 +883,20 @@ export const prompts = {
834
883
  dialogCore.open(
835
884
  (close) => (
836
885
  <div>
837
- <DialogHeader title={options?.title ?? "Uuups"} icon={options?.icon ?? "ti ti-alert-circle"} close={close} />
886
+ <DialogHeader title={options?.title ?? "Error"} icon={options?.icon ?? "ti ti-alert-circle"} close={close} />
838
887
 
839
888
  <div class="font-xs p-4 text-sm">{content}</div>
840
889
 
841
890
  <div class="flex justify-end gap-3">
842
891
  <button onClick={() => close()} class="btn-primary btn-sm">
843
- {options?.confirmText || "Ok .. me sad now"}
892
+ {options?.confirmText || "Close"}
844
893
  </button>
845
894
  </div>
846
895
  </div>
847
896
  ),
848
897
  {
849
898
  panelClassName: getPanelClassName({ ...options, variant: "danger" }),
899
+ contentClassName: getContentClassName(options?.surface),
850
900
  },
851
901
  ),
852
902