@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
@@ -1,4 +1,5 @@
1
1
  import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
2
+ import { mutation, timed } from "@valentinkolb/stdlib/solid";
2
3
  import { InputWrapper, createInputA11y } from "./util";
3
4
 
4
5
  type SelectOption =
@@ -10,6 +11,15 @@ type SelectOption =
10
11
  icon?: string;
11
12
  };
12
13
 
14
+ /**
15
+ * Async loader for searchable selects. Receives the current query
16
+ * (empty on initial open) and an AbortSignal that's tripped when the
17
+ * caller types again before the previous request resolves, or when
18
+ * the dropdown closes mid-flight. Throw to surface an error in the
19
+ * dropdown body — users get a Retry button.
20
+ */
21
+ type FetchDataFn = (query: string, signal: AbortSignal) => Promise<SelectOption[]>;
22
+
13
23
  type SelectInputProps = {
14
24
  label?: string;
15
25
  description?: string;
@@ -19,7 +29,22 @@ type SelectInputProps = {
19
29
  value?: () => string | undefined;
20
30
  onChange?: (value: string) => void;
21
31
  error?: () => string | undefined;
22
- options: SelectOption[];
32
+ /** Static option list. Required when `fetchData` is not set; ignored
33
+ * (still allowed for type ergonomics) when it is. */
34
+ options?: SelectOption[];
35
+ /** Async loader. When set, the dropdown becomes searchable: a search
36
+ * field renders at the top, the body shows loading / error / results,
37
+ * and `options` is ignored. The fetcher is wrapped in a stdlib
38
+ * mutation internally so loading + error + abort come for free. */
39
+ fetchData?: FetchDataFn;
40
+ /** Cached label for the currently-selected id when the caller already
41
+ * knows it (e.g. from a server-side join). Lets the trigger render
42
+ * the right text immediately on first paint, without waiting for
43
+ * fetchData to find the id. Falls back to a small internal cache
44
+ * populated when the user picks options, then to the id itself. */
45
+ selectedLabel?: () => string | undefined;
46
+ /** Debounce window for fetchData calls, in ms. Default 200. */
47
+ fetchDebounceMs?: number;
23
48
  required?: boolean;
24
49
  clearable?: boolean;
25
50
  disabled?: boolean;
@@ -31,11 +56,58 @@ const SelectInput = (props: SelectInputProps) => {
31
56
  const activeIcon = () => props.activeIcon ?? "ti ti-chevron-up";
32
57
  const disabled = () => props.disabled ?? false;
33
58
  const clearable = () => props.clearable ?? false;
59
+ const isSearchable = () => Boolean(props.fetchData);
60
+
61
+ // Normalize a raw option (string | object) into the always-object shape
62
+ // the renderer expects. Used for both static options and fetchData
63
+ // results so the rendering loop doesn't need to branch.
64
+ const normalize = (option: SelectOption) =>
65
+ typeof option === "object" ? { ...option, label: option.label || option.id } : { id: option, label: option };
66
+
67
+ // ── Search-mode state ────────────────────────────────────────────────
68
+ // Only meaningful when fetchData is set. Defaults are inert otherwise
69
+ // so static-mode callers see exactly the previous behaviour.
70
+ const [searchQuery, setSearchQuery] = createSignal("");
71
+
72
+ // Internal label cache — populated whenever the user picks an option.
73
+ // Lets the trigger keep rendering the right label after dropdown
74
+ // close + reopen, even if the new fetchData call no longer surfaces
75
+ // that record (e.g. the user typed a different filter).
76
+ const [labelCache, setLabelCache] = createSignal<Record<string, string>>({});
77
+
78
+ // Wrap the caller's fetchData in a stdlib mutation so we get:
79
+ // - reactive loading / error / data signals
80
+ // - one in-flight request at a time (we explicitly abort() before
81
+ // each new mutate() call in the debounced trigger below)
82
+ // - AbortSignal threaded through to fetch()
83
+ // When fetchData is undefined we still create the mutation but never
84
+ // call it — keeps the hook order stable across re-renders.
85
+ const fetchMut = mutation.create<{ id: string; label: string; description?: string; icon?: string }[], string>({
86
+ mutation: async (query, { abortSignal }) => {
87
+ if (!props.fetchData) return [];
88
+ const raw = await props.fetchData(query, abortSignal);
89
+ return raw.map(normalize);
90
+ },
91
+ });
92
+
93
+ const triggerFetch = (query: string) => {
94
+ if (!props.fetchData) return;
95
+ fetchMut.abort(); // cancel any in-flight previous request
96
+ void fetchMut.mutate(query);
97
+ };
98
+
99
+ // 200ms is a sweet spot: fast enough that typing feels live, slow
100
+ // enough to avoid hammering the server on every keystroke.
101
+ const debounce = timed.debounce((q: string) => triggerFetch(q), props.fetchDebounceMs ?? 200);
34
102
 
35
- const options = () =>
36
- props.options.map((option) =>
37
- typeof option === "object" ? { ...option, label: option.label || option.id } : { id: option, label: option },
38
- );
103
+ // ── Options resolution ───────────────────────────────────────────────
104
+ // In static mode → the `options` prop. In fetch mode → the mutation's
105
+ // last successful payload. Either way the renderer sees the same
106
+ // normalized {id, label, ...} shape.
107
+ const options = createMemo(() => {
108
+ if (isSearchable()) return fetchMut.data() ?? [];
109
+ return (props.options ?? []).map(normalize);
110
+ });
39
111
 
40
112
  const [isOpen, setIsOpen] = createSignal(false);
41
113
  const [focusedIndex, setFocusedIndex] = createSignal(-1);
@@ -44,9 +116,24 @@ const SelectInput = (props: SelectInputProps) => {
44
116
 
45
117
  let triggerRef: HTMLDivElement | undefined;
46
118
  let dialogRef: HTMLDialogElement | undefined;
119
+ let searchInputRef: HTMLInputElement | undefined;
47
120
  let optionRefs: HTMLDivElement[] = [];
48
121
 
49
- const selectedOption = createMemo(() => options().find((option) => option.id === props.value?.()));
122
+ // Resolve the currently-selected option's display row. Tries (in
123
+ // order): the live options list, the internal cache, the caller's
124
+ // selectedLabel getter, finally the id itself as a last-resort
125
+ // fallback so the trigger never goes blank when a value is set.
126
+ const selectedOption = createMemo(() => {
127
+ const id = props.value?.();
128
+ if (!id) return undefined;
129
+ const fromList = options().find((o) => o.id === id);
130
+ if (fromList) return fromList;
131
+ const cached = labelCache()[id];
132
+ if (cached) return { id, label: cached };
133
+ const fromProp = props.selectedLabel?.();
134
+ if (fromProp) return { id, label: fromProp };
135
+ return { id, label: id };
136
+ });
50
137
 
51
138
  const syncTheme = () => {
52
139
  if (typeof document === "undefined") return;
@@ -80,6 +167,15 @@ const SelectInput = (props: SelectInputProps) => {
80
167
  if (!open) {
81
168
  dialogRef?.close();
82
169
  setFocusedIndex(-1);
170
+ // Bail any in-flight request so a late response doesn't repaint
171
+ // a closed dropdown with stale results. Reset query so the next
172
+ // open starts fresh (matches user expectation of "open === fresh
173
+ // search").
174
+ if (isSearchable()) {
175
+ debounce.cancel();
176
+ fetchMut.abort();
177
+ setSearchQuery("");
178
+ }
83
179
  return;
84
180
  }
85
181
 
@@ -90,7 +186,10 @@ const SelectInput = (props: SelectInputProps) => {
90
186
  const rect = triggerRef.getBoundingClientRect();
91
187
  const spaceBelow = window.innerHeight - rect.bottom;
92
188
  const spaceAbove = rect.top;
93
- const dropdownMaxHeight = 260; // max-h-60 = 15rem ~ 240px + padding
189
+ // Searchable dropdowns are taller because of the sticky search
190
+ // header on top — bump the height budget so the auto-flip logic
191
+ // doesn't choose the wrong side.
192
+ const dropdownMaxHeight = isSearchable() ? 320 : 260;
94
193
 
95
194
  dialogRef.style.left = `${rect.left}px`;
96
195
  dialogRef.style.width = `${rect.width}px`;
@@ -106,10 +205,23 @@ const SelectInput = (props: SelectInputProps) => {
106
205
  }
107
206
 
108
207
  dialogRef.showModal();
208
+
209
+ // In search mode: kick off an empty-query fetch so the user sees
210
+ // recent / default results immediately, and move keyboard focus
211
+ // into the search input so they can start typing right away.
212
+ if (isSearchable()) {
213
+ triggerFetch("");
214
+ // Defer the focus until the dialog is actually rendered.
215
+ queueMicrotask(() => searchInputRef?.focus());
216
+ }
109
217
  }
110
218
  };
111
219
 
112
220
  const selectOption = (option: { id: string; label: string }) => {
221
+ // Cache the picked label so the trigger keeps showing it after
222
+ // close + reopen, even if the next fetchData call doesn't surface
223
+ // this id (e.g. user typed a different filter mid-flow).
224
+ setLabelCache({ ...labelCache(), [option.id]: option.label });
113
225
  props.onChange?.(option.id);
114
226
  toggleDropdown(false);
115
227
  triggerRef?.focus();
@@ -205,8 +317,15 @@ const SelectInput = (props: SelectInputProps) => {
205
317
  aria-required={props.required}
206
318
  aria-disabled={disabled()}
207
319
  >
208
- <Show when={selectedOption()} fallback={<span class="text-zinc-400 dark:text-zinc-500">{placeholder()}</span>}>
209
- <span class="text-zinc-700 dark:text-zinc-300">{selectedOption()!.label}</span>
320
+ {/* `block truncate` keeps long option labels on one line —
321
+ in narrow toolbar selects (group-by row, sort row,
322
+ filter row) the trigger stays single-row instead of
323
+ wrapping to two. */}
324
+ <Show
325
+ when={selectedOption()}
326
+ fallback={<span class="block truncate text-zinc-400 dark:text-zinc-500">{placeholder()}</span>}
327
+ >
328
+ <span class="block truncate text-zinc-700 dark:text-zinc-300">{selectedOption()!.label}</span>
210
329
  </Show>
211
330
  </div>
212
331
 
@@ -231,8 +350,51 @@ const SelectInput = (props: SelectInputProps) => {
231
350
  onClick={handleDialogClick}
232
351
  aria-label="Options"
233
352
  >
353
+ {/* Search input — only in fetchData mode. Borderless, no
354
+ icon, no divider underneath: the dropdown's own border
355
+ is the visual frame, an extra HR would just add noise.
356
+ onInput updates the visible query immediately and
357
+ schedules the debounced fetch. */}
358
+ <Show when={isSearchable()}>
359
+ <input
360
+ ref={searchInputRef}
361
+ type="text"
362
+ placeholder="Search..."
363
+ value={searchQuery()}
364
+ onInput={(e) => {
365
+ const v = e.currentTarget.value;
366
+ setSearchQuery(v);
367
+ debounce.debouncedFn(v);
368
+ }}
369
+ class="w-full bg-transparent px-3 py-1.5 text-sm text-zinc-700 placeholder:text-zinc-400 outline-none border-0 focus:ring-0 dark:text-zinc-300 dark:placeholder:text-zinc-500"
370
+ aria-label="Search options"
371
+ />
372
+ </Show>
234
373
  <div class="flex max-h-60 flex-col gap-1 overflow-y-auto" role="listbox" aria-label={props.label || "Options"}>
235
- <For each={options()} fallback={<div class="px-3 py-2 text-sm text-zinc-500 dark:text-zinc-400">No options available</div>}>
374
+ {/* Loading row single dim line. The previous results
375
+ would be more jarring than a small spinner here. */}
376
+ <Show when={isSearchable() && fetchMut.loading()}>
377
+ <div class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-zinc-500 dark:text-zinc-400">
378
+ <i class="ti ti-loader-2 animate-spin" /> Loading...
379
+ </div>
380
+ </Show>
381
+ {/* Error row — caller's fetchData threw. Surface message +
382
+ Retry. retry() reuses the last query without bouncing
383
+ through the debounce. */}
384
+ <Show when={isSearchable() && !fetchMut.loading() && fetchMut.error()}>
385
+ <div class="flex items-center gap-2 px-3 py-1.5 text-xs text-red-500">
386
+ <i class="ti ti-alert-triangle shrink-0" />
387
+ <span class="flex-1 truncate">{fetchMut.error()?.message ?? "Failed to load"}</span>
388
+ <button
389
+ type="button"
390
+ class="text-zinc-500 underline hover:text-zinc-700 dark:hover:text-zinc-300"
391
+ onClick={() => fetchMut.retry()}
392
+ >
393
+ Retry
394
+ </button>
395
+ </div>
396
+ </Show>
397
+ <For each={isSearchable() && (fetchMut.loading() || fetchMut.error()) ? [] : options()} fallback={<div class="px-3 py-1.5 text-xs text-zinc-400 dark:text-zinc-500">{isSearchable() ? "No results" : "No options available"}</div>}>
236
398
  {(option, index) => {
237
399
  const isSelected = () => option.id === props.value?.();
238
400
  const isFocused = () => index() === focusedIndex();
@@ -80,7 +80,7 @@ const Slider = ({
80
80
  disabled={disabled}
81
81
  aria-describedby={descId}
82
82
  class="w-full h-1.5 appearance-none cursor-pointer
83
- rounded-full
83
+ rounded-full [box-shadow:var(--theme-recess)]
84
84
  [&::-webkit-slider-thumb]:appearance-none
85
85
  [&::-webkit-slider-thumb]:w-3.5
86
86
  [&::-webkit-slider-thumb]:h-3.5
@@ -103,9 +103,8 @@ const Slider = ({
103
103
  ]:rounded-none
104
104
  ]:bg-(--slider-fill)
105
105
  focus-visible:outline-none
106
- focus-visible:[&::-webkit-slider-thumb]:ring-2
107
- focus-visible:[&::-webkit-slider-thumb]:ring-zinc-400
108
- focus-visible:[&::-webkit-slider-thumb]:ring-offset-2
106
+ focus-visible:[&::-webkit-slider-thumb]:[box-shadow:var(--theme-focus-ring)]
107
+ focus-visible:[&::-moz-range-thumb]:[box-shadow:var(--theme-focus-ring)]
109
108
  disabled:cursor-not-allowed
110
109
  disabled:[&::-webkit-slider-thumb]:bg-zinc-400
111
110
  disabled:[&::-moz-range-thumb]:bg-zinc-400"
@@ -25,11 +25,12 @@ const Switch = ({ label, value, onChange, disabled = false }: SwitchInputProps)
25
25
  class={`
26
26
  relative transition-colors
27
27
  w-9 h-5 rounded-full
28
-
28
+ [box-shadow:var(--theme-recess)]
29
+
29
30
  bg-zinc-200 dark:bg-zinc-600/40
30
31
  peer-checked:bg-blue-500
31
32
 
32
- peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2
33
+ peer-focus-visible:[box-shadow:var(--theme-focus-ring)]
33
34
 
34
35
  peer-disabled:opacity-50
35
36
  `}
@@ -0,0 +1,212 @@
1
+ import { createMemo, For } from "solid-js";
2
+ import type { Completion, Suggestion } from "../completion";
3
+ import type { PanesValue } from "../misc/Panes";
4
+ import AutocompleteEditor from "./AutocompleteEditor";
5
+ import TextInput from "./TextInput";
6
+
7
+ export type TemplateVariableKind = "string" | "email" | "url" | "number" | "boolean" | "array" | "object";
8
+
9
+ export type TemplateVariable = {
10
+ name: string;
11
+ kind?: TemplateVariableKind;
12
+ };
13
+
14
+ export type TemplateEditorProps = {
15
+ value: () => string;
16
+ onInput: (value: string) => void;
17
+ variables: readonly TemplateVariable[];
18
+ lines?: number;
19
+ fill?: boolean;
20
+ placeholder?: string;
21
+ };
22
+
23
+ export type TemplatePreviewProps = {
24
+ html: () => string;
25
+ };
26
+
27
+ export type TemplateSampleDataProps = {
28
+ variables: readonly TemplateVariable[];
29
+ values: () => Record<string, string>;
30
+ onChange: (name: string, value: string) => void;
31
+ };
32
+
33
+ export const createTemplateEditorPanesValue = (): PanesValue => ({
34
+ root: {
35
+ type: "split",
36
+ id: "template-editor-root",
37
+ direction: "horizontal",
38
+ sizes: [50, 50],
39
+ children: [
40
+ {
41
+ type: "leaf",
42
+ id: "template-editor-source",
43
+ presentation: "tabs",
44
+ elementIds: ["html"],
45
+ activeElementId: "html",
46
+ },
47
+ {
48
+ type: "leaf",
49
+ id: "template-editor-output",
50
+ presentation: "tabs",
51
+ elementIds: ["preview", "sample-data"],
52
+ activeElementId: "preview",
53
+ },
54
+ ],
55
+ },
56
+ });
57
+
58
+ const HTML_TAGS = [
59
+ { name: "p", snippet: "<p></p>", hint: "paragraph" },
60
+ { name: "a", snippet: '<a href="{{LOGIN_URL}}">Link</a>', hint: "link" },
61
+ { name: "strong", snippet: "<strong></strong>", hint: "bold" },
62
+ { name: "em", snippet: "<em></em>", hint: "emphasis" },
63
+ { name: "br", snippet: "<br>", hint: "line break" },
64
+ { name: "ul", snippet: "<ul>\n <li></li>\n</ul>", hint: "list" },
65
+ { name: "table", snippet: "<table>\n <tr><td></td></tr>\n</table>", hint: "table" },
66
+ ] as const;
67
+
68
+ const escapeHtml = (value: string): string =>
69
+ value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
70
+
71
+ const buildSuggestion = (text: string, hint: string, label?: string): Suggestion => ({
72
+ text,
73
+ hint,
74
+ label,
75
+ appendSpace: false,
76
+ });
77
+
78
+ const makeCompletions = (variables: readonly TemplateVariable[]): Completion[] => [
79
+ {
80
+ trigger: "{",
81
+ dropdown: true,
82
+ allowAfterWord: true,
83
+ suggest: (query, ctx) => {
84
+ const normalized = query.toLowerCase();
85
+ const hasLeadingBrace = ctx.tokenStart > 0 && ctx.fullText[ctx.tokenStart - 1] === "{";
86
+ const open = hasLeadingBrace ? "{" : "{{";
87
+ const variableSuggestions = variables
88
+ .filter((variable) => variable.name.toLowerCase().startsWith(normalized))
89
+ .map((variable) => buildSuggestion(`${open}${variable.name}}}`, variable.kind ?? "string", variable.name));
90
+
91
+ const sectionSuggestions = variables
92
+ .filter((variable) => variable.kind === "array" || variable.kind === "object" || variable.kind === "boolean")
93
+ .filter((variable) => variable.name.toLowerCase().startsWith(normalized))
94
+ .map((variable) => buildSuggestion(`${open}#${variable.name}}}\n \n{{/${variable.name}}}`, "section", `#${variable.name}`));
95
+
96
+ return [...variableSuggestions, ...sectionSuggestions];
97
+ },
98
+ },
99
+ {
100
+ trigger: "#",
101
+ dropdown: true,
102
+ allowAfterWord: true,
103
+ suggest: (query) => {
104
+ const normalized = query.toLowerCase();
105
+ return variables
106
+ .filter((variable) => variable.kind === "array" || variable.kind === "object" || variable.kind === "boolean")
107
+ .filter((variable) => variable.name.toLowerCase().startsWith(normalized))
108
+ .map((variable) => buildSuggestion(`#${variable.name}}}\n \n{{/${variable.name}}}`, "section", `#${variable.name}`));
109
+ },
110
+ },
111
+ {
112
+ trigger: "<",
113
+ dropdown: true,
114
+ allowAfterWord: true,
115
+ suggest: (query) => {
116
+ const normalized = query.toLowerCase();
117
+ return HTML_TAGS.filter((tag) => tag.name.startsWith(normalized)).map((tag) => buildSuggestion(tag.snippet, tag.hint, tag.name));
118
+ },
119
+ },
120
+ ];
121
+
122
+ const highlightMustacheToken = (token: string): string => {
123
+ const inner = token
124
+ .replace(/^\{\{\{?/, "")
125
+ .replace(/\}\}\}?$/, "")
126
+ .trim();
127
+ const prefix = inner[0];
128
+ const tone =
129
+ prefix === "#"
130
+ ? "text-emerald-600 dark:text-emerald-400"
131
+ : prefix === "/"
132
+ ? "text-amber-600 dark:text-amber-400"
133
+ : prefix === "^"
134
+ ? "text-rose-600 dark:text-rose-400"
135
+ : token.startsWith("{{{")
136
+ ? "text-red-600 dark:text-red-400"
137
+ : "text-violet-600 dark:text-violet-400";
138
+ return `<span class="${tone}">${escapeHtml(token)}</span>`;
139
+ };
140
+
141
+ const highlightTag = (tag: string): string => {
142
+ const match = tag.match(/^(&lt;\/?)([a-zA-Z][\w:-]*)([\s\S]*?)(&gt;)$/);
143
+ if (!match) return `<span class="text-zinc-500 dark:text-zinc-400">${tag}</span>`;
144
+ const open = match[1] ?? "";
145
+ const name = match[2] ?? "";
146
+ const attrs = match[3] ?? "";
147
+ const close = match[4] ?? "";
148
+ const attrHtml = attrs.replace(
149
+ /([\w:-]+)(=)(&quot;[\s\S]*?&quot;|'[\s\S]*?'|[^\s&]+)/g,
150
+ '<span class="text-sky-600 dark:text-sky-400">$1</span><span class="text-zinc-400">$2</span><span class="text-orange-600 dark:text-orange-400">$3</span>',
151
+ );
152
+ return `<span class="text-zinc-400">${open}</span><span class="text-blue-600 dark:text-blue-400">${name}</span>${attrHtml}<span class="text-zinc-400">${close}</span>`;
153
+ };
154
+
155
+ const highlightTemplate = (text: string): string => {
156
+ const stashed: string[] = [];
157
+ const escaped = escapeHtml(text).replace(/{{{?[\s\S]*?}}}?/g, (token) => {
158
+ const marker = `\uE000${stashed.length}\uE001`;
159
+ stashed.push(highlightMustacheToken(token));
160
+ return marker;
161
+ });
162
+
163
+ const withHtml = escaped.replace(/&lt;!--[\s\S]*?--&gt;|&lt;\/?[a-zA-Z][\s\S]*?&gt;/g, (tag) => {
164
+ if (tag.startsWith("&lt;!--")) return `<span class="text-zinc-400 dark:text-zinc-500">${tag}</span>`;
165
+ return highlightTag(tag);
166
+ });
167
+
168
+ return withHtml.replace(/\uE000(\d+)\uE001/g, (_, index) => stashed[Number(index)] ?? "");
169
+ };
170
+
171
+ export default function TemplateEditor(props: TemplateEditorProps) {
172
+ const completions = createMemo(() => makeCompletions(props.variables));
173
+
174
+ return (
175
+ <AutocompleteEditor
176
+ value={props.value}
177
+ onInput={props.onInput}
178
+ lines={props.lines ?? 22}
179
+ fill={props.fill}
180
+ spellcheck={false}
181
+ placeholder={props.placeholder ?? "Write HTML with {{MUSTACHE_VALUES}}..."}
182
+ highlight={highlightTemplate}
183
+ completions={completions()}
184
+ />
185
+ );
186
+ }
187
+
188
+ export function TemplatePreview(props: TemplatePreviewProps) {
189
+ return (
190
+ <section class="paper flex h-full min-h-0 flex-col overflow-hidden">
191
+ <iframe class="min-h-0 flex-1 bg-white" sandbox="" srcdoc={props.html()} title="Template preview" />
192
+ </section>
193
+ );
194
+ }
195
+
196
+ export function TemplateSampleData(props: TemplateSampleDataProps) {
197
+ return (
198
+ <section class="paper h-full min-h-0 overflow-auto p-3">
199
+ <div class="grid gap-3">
200
+ <For each={props.variables}>
201
+ {(variable) => (
202
+ <TextInput
203
+ label={`{{${variable.name}}}`}
204
+ value={() => props.values()[variable.name] ?? ""}
205
+ onInput={(value) => props.onChange(variable.name, value)}
206
+ />
207
+ )}
208
+ </For>
209
+ </div>
210
+ </section>
211
+ );
212
+ }