@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,3 +1,4 @@
1
+ import { dates, type DateContext } from "@valentinkolb/stdlib";
1
2
  import { InputWrapper, createInputA11y } from "./util";
2
3
 
3
4
  type DateTimeInputProps = {
@@ -11,15 +12,25 @@ type DateTimeInputProps = {
11
12
  disabled?: boolean;
12
13
  /** Use date-only input instead of datetime-local */
13
14
  dateOnly?: boolean;
15
+ /** Optional stdlib date context. When set, datetime values are edited in this timezone. */
16
+ dateConfig?: DateContext;
17
+ /** Convenience override for dateConfig.timeZone. */
18
+ timeZone?: string;
14
19
  };
15
20
 
21
+ const hasInstantOffset = (value: string) => /[T\s].*([zZ]|[+-]\d{2}:?\d{2})$/.test(value);
22
+
16
23
  /**
17
- * Date/DateTime input component using native browser inputs
24
+ * Date/DateTime input component using native browser inputs.
25
+ *
26
+ * @deprecated Use `DatePicker` or `DateTimePicker` from `@valentinkolb/cloud/ui`
27
+ * for new UI. This component stays exported for compatibility with older apps.
28
+ *
18
29
  * @param label - Optional label text
19
30
  * @param description - Optional description text
20
31
  * @param placeholder - Placeholder text (not shown in date inputs)
21
32
  * @param value - Reactive value getter (ISO string or datetime-local format)
22
- * @param onChange - Called on change event with datetime-local format string
33
+ * @param onChange - Called with a date key, local datetime string, or UTC instant when dateConfig/timeZone is set
23
34
  * @param error - Reactive error message getter
24
35
  * @param required - Show required asterisk after label
25
36
  * @param disabled - Disable the input
@@ -30,13 +41,22 @@ const DateTimeInput = (props: DateTimeInputProps) => {
30
41
  const dateOnly = () => props.dateOnly ?? false;
31
42
  const icon = () => (dateOnly() ? "ti ti-calendar" : "ti ti-calendar-time");
32
43
  const a11y = createInputA11y({ description: props.description, error: props.error });
44
+ const dateContext = (): DateContext => ({
45
+ ...props.dateConfig,
46
+ timeZone: props.timeZone ?? props.dateConfig?.timeZone,
47
+ });
48
+ const timezone = () => dateContext().timeZone;
33
49
 
34
50
  // Convert ISO string to input format if needed
35
51
  const inputValue = () => {
36
52
  const v = props.value?.();
37
53
  if (!v) return "";
38
54
  // If it's already in the right format, return as-is
39
- if (!v.includes("Z") && !v.includes("+")) return v;
55
+ if (!hasInstantOffset(v)) return v;
56
+ if (timezone()) {
57
+ if (dateOnly()) return dates.formatDateKey(v, dateContext());
58
+ return dates.instantToZonedInput(v, timezone()!);
59
+ }
40
60
  // Convert ISO to local datetime-local format
41
61
  const d = new Date(v);
42
62
  if (dateOnly()) {
@@ -51,6 +71,11 @@ const DateTimeInput = (props: DateTimeInputProps) => {
51
71
  return `${year}-${month}-${day}T${hours}:${minutes}`;
52
72
  };
53
73
 
74
+ const outputValue = (value: string) => {
75
+ if (!value || dateOnly() || !timezone()) return value;
76
+ return dates.zonedDateTimeToInstant(value, timezone()!, { disambiguation: "compatible" });
77
+ };
78
+
54
79
  return (
55
80
  <InputWrapper
56
81
  label={props.label}
@@ -70,7 +95,7 @@ const DateTimeInput = (props: DateTimeInputProps) => {
70
95
  type={dateOnly() ? "date" : "datetime-local"}
71
96
  class={`input w-full pl-9 ${disabled() ? "cursor-not-allowed opacity-50" : ""}`}
72
97
  value={inputValue()}
73
- onChange={(e) => props.onChange?.(e.currentTarget.value)}
98
+ onChange={(e) => props.onChange?.(outputValue(e.currentTarget.value))}
74
99
  disabled={disabled()}
75
100
  aria-label={!props.label ? props.placeholder : undefined}
76
101
  aria-describedby={a11y.ariaDescribedBy()}
@@ -0,0 +1,116 @@
1
+ import { dropzone } from "@valentinkolb/stdlib/solid";
2
+ import { type Accessor, type JSX, Show } from "solid-js";
3
+ import { createInputA11y, InputWrapper } from "./util";
4
+
5
+ export type FileDropzoneProps = {
6
+ label?: string | JSX.Element;
7
+ description?: string | JSX.Element;
8
+ ariaLabel?: string;
9
+ accept?: string;
10
+ multiple?: boolean;
11
+ required?: boolean;
12
+ disabled?: boolean;
13
+ busy?: boolean | Accessor<boolean>;
14
+ error?: string | Accessor<string | null | undefined>;
15
+ icon?: string;
16
+ title?: string;
17
+ subtitle?: string;
18
+ hint?: string;
19
+ class?: string;
20
+ onDrop: (files: File[]) => void | Promise<void>;
21
+ };
22
+
23
+ const resolveMaybe = <T,>(value: T | Accessor<T> | undefined): T | undefined =>
24
+ typeof value === "function" ? (value as Accessor<T>)() : value;
25
+
26
+ export default function FileDropzone(props: FileDropzoneProps) {
27
+ let inputRef: HTMLInputElement | undefined;
28
+ const busy = () => resolveMaybe(props.busy) ?? false;
29
+ const disabled = () => (props.disabled ?? false) || busy();
30
+ const error = () => resolveMaybe(props.error) ?? undefined;
31
+ const a11y = createInputA11y({ description: props.description, error });
32
+
33
+ const emitFiles = (files: File[]) => {
34
+ if (disabled() || files.length === 0) return;
35
+ void props.onDrop(props.multiple === false ? files.slice(0, 1) : files);
36
+ };
37
+
38
+ const dz = dropzone.create({
39
+ accept: props.accept,
40
+ onDrop: emitFiles,
41
+ });
42
+
43
+ const zoneClass = () => {
44
+ const base =
45
+ "group relative flex min-h-28 w-full flex-col items-center justify-center gap-2 rounded-lg border px-4 py-5 text-center text-sm transition-[background-color,border-color,box-shadow,color] duration-150 focus-ui";
46
+ const enabled = disabled() ? "cursor-not-allowed opacity-60" : "cursor-pointer";
47
+ const state = dz.invalidDrag()
48
+ ? "border-red-400 bg-red-50/80 text-red-700 dark:border-red-500/70 dark:bg-red-950/35 dark:text-red-200"
49
+ : dz.isDragging()
50
+ ? "border-blue-400 bg-blue-50/80 text-blue-700 dark:border-blue-500/70 dark:bg-blue-950/35 dark:text-blue-200"
51
+ : "border-zinc-200/80 bg-zinc-50/80 text-secondary hover:border-zinc-300 hover:bg-white dark:border-zinc-800 dark:bg-zinc-900/55 dark:hover:border-zinc-700 dark:hover:bg-zinc-900";
52
+
53
+ return `${base} ${enabled} ${state} ${props.class ?? ""}`;
54
+ };
55
+
56
+ const title = () => {
57
+ if (busy()) return "Uploading...";
58
+ if (dz.invalidDrag()) return "File type not accepted";
59
+ if (dz.isDragging()) return "Drop to upload";
60
+ return props.title ?? "Drop files or click to choose";
61
+ };
62
+
63
+ const subtitle = () => {
64
+ if (dz.invalidDrag()) return "Choose a file that matches this field.";
65
+ return props.subtitle;
66
+ };
67
+
68
+ return (
69
+ <InputWrapper
70
+ label={props.label}
71
+ description={props.description}
72
+ error={error()}
73
+ required={props.required}
74
+ inputId={a11y.inputId}
75
+ descriptionId={a11y.descriptionId}
76
+ errorId={a11y.errorId}
77
+ >
78
+ <button
79
+ id={a11y.inputId}
80
+ type="button"
81
+ class={zoneClass()}
82
+ onClick={() => inputRef?.click()}
83
+ disabled={disabled()}
84
+ aria-label={props.ariaLabel ?? (typeof props.label === "string" ? props.label : props.title)}
85
+ aria-describedby={a11y.ariaDescribedBy()}
86
+ {...dz.handlers}
87
+ >
88
+ <span class="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-lg text-blue-600 shadow-[var(--theme-shadow-elevated)] transition-colors group-hover:text-blue-700 dark:bg-zinc-950 dark:text-blue-300">
89
+ <i class={`ti ${busy() ? "ti-loader-2 animate-spin" : (props.icon ?? "ti-cloud-upload")}`} aria-hidden="true" />
90
+ </span>
91
+ <span class="flex flex-col gap-0.5">
92
+ <span class="font-medium text-primary">{title()}</span>
93
+ <Show when={subtitle()}>
94
+ <span class="text-xs text-dimmed">{subtitle()}</span>
95
+ </Show>
96
+ <Show when={props.hint}>
97
+ <span class="text-[11px] text-dimmed">{props.hint}</span>
98
+ </Show>
99
+ </span>
100
+ </button>
101
+ <input
102
+ ref={inputRef}
103
+ type="file"
104
+ class="hidden"
105
+ accept={props.accept}
106
+ multiple={props.multiple ?? true}
107
+ disabled={disabled()}
108
+ onChange={(event) => {
109
+ const files = Array.from(event.currentTarget.files ?? []);
110
+ event.currentTarget.value = "";
111
+ emitFiles(files);
112
+ }}
113
+ />
114
+ </InputWrapper>
115
+ );
116
+ }
@@ -0,0 +1,116 @@
1
+ import { fuzzy } from "@valentinkolb/stdlib";
2
+ import { ICON_OPTIONS, type IconOption } from "../../shared/icons";
3
+ import SelectInput from "./Select";
4
+
5
+ type IconInputProps = {
6
+ label?: string;
7
+ description?: string;
8
+ placeholder?: string;
9
+ /**
10
+ * The currently-selected icon class string (e.g. `"ti ti-currency-euro"`).
11
+ * Empty / undefined means "no icon picked". Stored as the full Tabler
12
+ * class so consumers can render directly via `<i class={value}>`
13
+ * without prepending `ti ` themselves; render sites that DO prepend
14
+ * produce a duplicate-token (`ti ti ti-foo`) which the browser
15
+ * tolerates as a no-op.
16
+ */
17
+ value?: () => string | undefined;
18
+ onChange?: (next: string) => void;
19
+ error?: () => string | undefined;
20
+ required?: boolean;
21
+ /** Default true — empty selection is a valid state for icons. */
22
+ clearable?: boolean;
23
+ disabled?: boolean;
24
+ /**
25
+ * Override the icon catalogue. Defaults to the curated `ICON_OPTIONS`
26
+ * exported from `cloud/shared/icons.ts`. Useful for app-specific
27
+ * sub-sets (e.g. only finance icons in a finance picker).
28
+ */
29
+ options?: IconOption[];
30
+ /**
31
+ * How many results the fuzzy search returns at most. Default 50 —
32
+ * enough to scroll through, not so many that the dropdown becomes a
33
+ * wall of icons. Empty queries bypass this cap and show the full
34
+ * catalogue alphabetically.
35
+ */
36
+ searchLimit?: number;
37
+ };
38
+
39
+ /**
40
+ * Searchable icon picker — wraps `SelectInput` in `fetchData` mode and
41
+ * runs `fuzzy.filter` from `@valentinkolb/stdlib` over the catalogue
42
+ * locally. No network: the icon list is bundled, the "fetcher" is a
43
+ * synchronous filter wrapped in a Promise so it slots into
44
+ * SelectInput's async loader contract.
45
+ *
46
+ * Each icon entry carries a `keywords` synonym list — searching "money"
47
+ * matches `ti ti-currency-euro`, `ti ti-coin`, `ti ti-wallet`, etc.
48
+ * Symbol forms work too: typing `€` finds the Euro icon.
49
+ *
50
+ * Empty query (dropdown just opened, user hasn't typed) returns the
51
+ * full catalogue sorted alphabetically by label so the user can
52
+ * browse-not-search if they prefer.
53
+ *
54
+ * The picker stores the full Tabler class string as the value (e.g.
55
+ * `"ti ti-currency-euro"`). Render the selected icon with
56
+ * `<i class={value}>` — no need to add `ti ` yourself.
57
+ */
58
+ export default function IconInput(props: IconInputProps) {
59
+ const options = () => props.options ?? ICON_OPTIONS;
60
+ const limit = () => props.searchLimit ?? 50;
61
+
62
+ // Pre-compute the searchable string per option once per render of
63
+ // the catalogue. Since the catalogue is a constant in the common
64
+ // case, this memoizes effectively across edit sessions.
65
+ const searchKey = (entry: IconOption) =>
66
+ [entry.label, ...entry.keywords].join(" ").toLowerCase();
67
+
68
+ const fetcher = (query: string): Promise<IconOption[]> => {
69
+ const trimmed = query.trim();
70
+ if (trimmed.length === 0) {
71
+ // No query: alphabetical-by-label, full catalogue. Browsers
72
+ // happily render hundreds of dropdown rows at this size; if it
73
+ // ever becomes a perf concern we'd switch to virtualisation
74
+ // rather than truncating the list.
75
+ const all = [...options()].sort((a, b) =>
76
+ a.label.localeCompare(b.label, undefined, { sensitivity: "base" }),
77
+ );
78
+ return Promise.resolve(all);
79
+ }
80
+ const matches = fuzzy.filter(trimmed.toLowerCase(), options(), {
81
+ key: searchKey,
82
+ limit: limit(),
83
+ });
84
+ return Promise.resolve(matches.map((m) => m.item));
85
+ };
86
+
87
+ // Resolve the selected option's label so the trigger renders the
88
+ // friendly name (and dropdown glyph) immediately, even before the
89
+ // user opens the picker.
90
+ const selectedLabel = () => {
91
+ const v = props.value?.();
92
+ if (!v) return undefined;
93
+ return options().find((o) => o.id === v)?.label;
94
+ };
95
+
96
+ return (
97
+ <SelectInput
98
+ label={props.label}
99
+ description={props.description}
100
+ placeholder={props.placeholder ?? "Pick an icon…"}
101
+ icon="ti ti-icons"
102
+ value={props.value}
103
+ onChange={props.onChange}
104
+ error={props.error}
105
+ required={props.required}
106
+ clearable={props.clearable ?? true}
107
+ disabled={props.disabled}
108
+ fetchData={fetcher}
109
+ selectedLabel={selectedLabel}
110
+ // No debounce: filtering is local + sub-millisecond, debouncing
111
+ // just adds latency. SelectInput's default 200ms is built for
112
+ // network calls.
113
+ fetchDebounceMs={0}
114
+ />
115
+ );
116
+ }
@@ -14,6 +14,22 @@ type ImageInputProps = {
14
14
  error?: () => string | undefined;
15
15
  required?: boolean;
16
16
  disabled?: boolean;
17
+ /**
18
+ * Custom file→data-URL transform applied to the picked file before
19
+ * emitting via `onChange`. Default = `img.presets.avatar` which
20
+ * produces a 512×512 cropped WebP — fine for square avatars but
21
+ * the wrong shape for banners / title images. Pass a custom
22
+ * transform (e.g. one that preserves aspect ratio and caps the
23
+ * longest side) to override. Receives the user-picked `File`,
24
+ * returns a base64 data-URL string.
25
+ */
26
+ transform?: (file: File) => Promise<string>;
27
+ /**
28
+ * File-picker `accept` attribute. Default matches the common
29
+ * raster formats the avatar preset handles. Override when a
30
+ * caller needs to allow / restrict different formats.
31
+ */
32
+ accept?: string;
17
33
  };
18
34
 
19
35
  /**
@@ -42,8 +58,9 @@ const ImageInput = (props: ImageInputProps) => {
42
58
 
43
59
  const selectImage = () => {
44
60
  if (disabled()) return;
45
- showFileDialog({ accept: ".jpg,.jpeg,.png,.gif,.webp" })
46
- .then((file) => img.presets.avatar(file))
61
+ const transform = props.transform ?? ((f: File) => img.presets.avatar(f));
62
+ showFileDialog({ accept: props.accept ?? ".jpg,.jpeg,.png,.gif,.webp" })
63
+ .then(transform)
47
64
  .then((image) => props.onChange?.(image));
48
65
  };
49
66