@valentinkolb/cloud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,19 @@
1
+ export * from "./misc";
2
+ export * from "./ipa";
3
+ export * from "./input";
4
+ export * from "./filter";
5
+ export * from "./widgets";
6
+ export { currentPathWithQuery, refreshCurrentPath, navigateTo } from "./navigation";
7
+ export { SettingsField, SettingsSaveBar, sameSettingValue, readSettingsError } from "./admin-settings";
8
+ export type { SettingsFieldProps, SettingsSaveBarProps } from "./admin-settings";
9
+ export { prompts, DialogHeader, createFormState } from "./prompts";
10
+ export type { PromptSearchItem, PromptSearchInput, PromptSearchOptions } from "./prompts";
11
+ export { dialogCore, createDialogCore } from "./dialog-core";
12
+ export type { DialogClose, OpenDialogOptions, DialogRender, DialogCore } from "./dialog-core";
13
+ export { default as SidebarLayout, SidebarFromSpec } from "./sidebar";
14
+ export type { SidebarSpec, SidebarRow, SidebarSection, SidebarTreeNode, SidebarTreeSpec } from "./sidebar";
15
+ // NOTE: islands (*.island.tsx) belong inside the consuming app's package, not
16
+ // in cloud-lib. The SSR plugin discovers islands by import-path suffix; barrel
17
+ // re-exports strip the `.island` segment and silently break hydration. Apps
18
+ // that need a complex stateful component build their own admin/page islands
19
+ // using the input primitives exported above (TextInput, Switch, ImageInput, …).
@@ -0,0 +1,55 @@
1
+ import type { CheckboxInputProps } from "./types";
2
+
3
+ /**
4
+ * Checkbox/Boolean input component
5
+ * @param label - Text displayed next to checkbox
6
+ * @param description - Optional description text below
7
+ * @param value - Reactive boolean value getter
8
+ * @param onChange - Called when checkbox state changes
9
+ * @param error - Reactive error message getter
10
+ * @param required - Show required asterisk
11
+ * @param disabled - Disable the checkbox
12
+ */
13
+ const CheckboxInput = ({ label, description, value, onChange, error, required = false, disabled = false }: CheckboxInputProps) => {
14
+ const inputId = crypto.randomUUID();
15
+
16
+ return (
17
+ <div class="flex flex-col gap-2 select-none">
18
+ <div class="flex flex-row items-center gap-2">
19
+ <input
20
+ id={inputId}
21
+ type="checkbox"
22
+ checked={value?.() || false}
23
+ onChange={(e) => onChange?.(e.target.checked)}
24
+ disabled={disabled}
25
+ aria-required={required}
26
+ aria-invalid={!!error?.()}
27
+ aria-describedby={error?.() ? `${inputId}-error` : undefined}
28
+ class="h-4 w-4"
29
+ />
30
+ {label && (
31
+ <label for={inputId} class={`text-xs select-none ${disabled ? "opacity-50" : "cursor-pointer"}`}>
32
+ {label}
33
+ {required && (
34
+ <span class="ml-0.5 text-red-500" aria-hidden="true">
35
+ *
36
+ </span>
37
+ )}
38
+ </label>
39
+ )}
40
+ </div>
41
+
42
+ {description && <p class="text-dimmed ml-6 text-xs">{description}</p>}
43
+
44
+ {error?.() && (
45
+ <p id={`${inputId}-error`} class="ml-6 text-sm text-red-500" role="alert" aria-live="polite">
46
+ {error()}
47
+ </p>
48
+ )}
49
+ </div>
50
+ );
51
+ };
52
+
53
+ export { CheckboxInput };
54
+ export const Checkbox = CheckboxInput;
55
+ export default CheckboxInput;
@@ -0,0 +1,122 @@
1
+ import { createSignal, Show } from "solid-js";
2
+ import { InputWrapper, createInputA11y } from "./util";
3
+ import type { ColorInputProps } from "./types";
4
+
5
+ /**
6
+ * Color input component using native color picker
7
+ */
8
+ const ColorInput = (props: ColorInputProps) => {
9
+ const disabled = () => props.disabled ?? false;
10
+ const compact = () => props.compact ?? !props.label;
11
+ const [isFocused, setIsFocused] = createSignal(false);
12
+ const inputId = crypto.randomUUID();
13
+ const a11y = createInputA11y({ description: props.description, error: props.error });
14
+
15
+ const currentColor = () => props.value?.() || "#3b82f6";
16
+ const isTransparent = () => props.isTransparent?.() ?? false;
17
+
18
+ // Compact version - just a clickable swatch
19
+ if (compact()) {
20
+ return (
21
+ <div class="relative inline-flex">
22
+ <button
23
+ type="button"
24
+ class={`w-7 h-7 border border-zinc-300 dark:border-zinc-600 rounded ${
25
+ disabled() ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-500"
26
+ }`}
27
+ style={`background-color: ${currentColor()}`}
28
+ onClick={() => document.getElementById(inputId)?.click()}
29
+ disabled={disabled()}
30
+ />
31
+ <input
32
+ id={inputId}
33
+ type="color"
34
+ class="absolute opacity-0 w-0 h-0"
35
+ value={currentColor()}
36
+ onInput={(e) => props.onChange?.(e.currentTarget.value)}
37
+ onChange={(e) => props.onChange?.(e.currentTarget.value)}
38
+ disabled={disabled()}
39
+ />
40
+ </div>
41
+ );
42
+ }
43
+
44
+ // Full version with label
45
+ return (
46
+ <InputWrapper
47
+ label={props.label}
48
+ description={props.description}
49
+ error={props.error?.()}
50
+ required={props.required}
51
+ inputId={a11y.inputId}
52
+ descriptionId={a11y.descriptionId}
53
+ errorId={a11y.errorId}
54
+ >
55
+ <div class="relative">
56
+ <div
57
+ class={`input flex items-center gap-2 transition-all ${isFocused() ? "border-blue-500 bg-white dark:border-blue-400 dark:bg-zinc-900" : ""} ${
58
+ disabled() || isTransparent() ? "cursor-not-allowed opacity-50" : "cursor-pointer"
59
+ }`}
60
+ onClick={() => {
61
+ if (!disabled() && !isTransparent()) {
62
+ document.getElementById(a11y.inputId)?.click();
63
+ }
64
+ }}
65
+ onKeyDown={(e) => {
66
+ if ((e.key === "Enter" || e.key === " ") && !disabled() && !isTransparent()) {
67
+ e.preventDefault();
68
+ document.getElementById(a11y.inputId)?.click();
69
+ }
70
+ }}
71
+ role="button"
72
+ tabIndex={disabled() || isTransparent() ? -1 : 0}
73
+ >
74
+ <Show
75
+ when={!isTransparent()}
76
+ fallback={
77
+ <div class="h-4 w-4 shrink-0 border border-zinc-300 dark:border-zinc-600 bg-[repeating-conic-gradient(#ccc_0_25%,transparent_0_50%)] bg-size-[6px_6px] rounded" />
78
+ }
79
+ >
80
+ <div
81
+ class="h-4 w-4 shrink-0 border border-zinc-300 dark:border-zinc-600 rounded"
82
+ style={`background-color: ${currentColor()}`}
83
+ />
84
+ </Show>
85
+ <span class="flex-1 font-mono text-sm uppercase leading-tight">{isTransparent() ? "transparent" : currentColor()}</span>
86
+ {props.transparent && (
87
+ <button
88
+ type="button"
89
+ class={`shrink-0 flex items-center justify-center p-0.5 transition-colors rounded ${
90
+ isTransparent()
91
+ ? "bg-zinc-200 dark:bg-zinc-700 text-primary font-medium"
92
+ : "text-dimmed hover:text-secondary hover:bg-zinc-100 dark:hover:bg-zinc-800"
93
+ }`}
94
+ onClick={(e) => {
95
+ e.stopPropagation();
96
+ props.onTransparentChange?.(!isTransparent());
97
+ }}
98
+ aria-label="Toggle transparent"
99
+ >
100
+ <i class="ti ti-grid-dots text-sm leading-none" />
101
+ </button>
102
+ )}
103
+ <input
104
+ id={a11y.inputId}
105
+ type="color"
106
+ class="absolute opacity-0 w-0 h-0"
107
+ value={currentColor()}
108
+ onInput={(e) => props.onChange?.(e.currentTarget.value)}
109
+ onChange={(e) => props.onChange?.(e.currentTarget.value)}
110
+ onFocus={() => setIsFocused(true)}
111
+ onBlur={() => setIsFocused(false)}
112
+ disabled={disabled() || isTransparent()}
113
+ aria-describedby={a11y.ariaDescribedBy()}
114
+ aria-invalid={!!props.error?.()}
115
+ />
116
+ </div>
117
+ </div>
118
+ </InputWrapper>
119
+ );
120
+ };
121
+
122
+ export default ColorInput;
@@ -0,0 +1,86 @@
1
+ import { InputWrapper, createInputA11y } from "./util";
2
+
3
+ type DateTimeInputProps = {
4
+ label?: string;
5
+ description?: string;
6
+ placeholder?: string;
7
+ value?: () => string | undefined | null;
8
+ onChange?: (value: string) => void;
9
+ error?: () => string | undefined;
10
+ required?: boolean;
11
+ disabled?: boolean;
12
+ /** Use date-only input instead of datetime-local */
13
+ dateOnly?: boolean;
14
+ };
15
+
16
+ /**
17
+ * Date/DateTime input component using native browser inputs
18
+ * @param label - Optional label text
19
+ * @param description - Optional description text
20
+ * @param placeholder - Placeholder text (not shown in date inputs)
21
+ * @param value - Reactive value getter (ISO string or datetime-local format)
22
+ * @param onChange - Called on change event with datetime-local format string
23
+ * @param error - Reactive error message getter
24
+ * @param required - Show required asterisk after label
25
+ * @param disabled - Disable the input
26
+ * @param dateOnly - Use date input instead of datetime-local
27
+ */
28
+ const DateTimeInput = (props: DateTimeInputProps) => {
29
+ const disabled = () => props.disabled ?? false;
30
+ const dateOnly = () => props.dateOnly ?? false;
31
+ const icon = () => (dateOnly() ? "ti ti-calendar" : "ti ti-calendar-time");
32
+ const a11y = createInputA11y({ description: props.description, error: props.error });
33
+
34
+ // Convert ISO string to input format if needed
35
+ const inputValue = () => {
36
+ const v = props.value?.();
37
+ if (!v) return "";
38
+ // If it's already in the right format, return as-is
39
+ if (!v.includes("Z") && !v.includes("+")) return v;
40
+ // Convert ISO to local datetime-local format
41
+ const d = new Date(v);
42
+ if (dateOnly()) {
43
+ return d.toISOString().slice(0, 10);
44
+ }
45
+ // Get local time
46
+ const year = d.getFullYear();
47
+ const month = String(d.getMonth() + 1).padStart(2, "0");
48
+ const day = String(d.getDate()).padStart(2, "0");
49
+ const hours = String(d.getHours()).padStart(2, "0");
50
+ const minutes = String(d.getMinutes()).padStart(2, "0");
51
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
52
+ };
53
+
54
+ return (
55
+ <InputWrapper
56
+ label={props.label}
57
+ description={props.description}
58
+ error={props.error?.()}
59
+ required={props.required}
60
+ inputId={a11y.inputId}
61
+ descriptionId={a11y.descriptionId}
62
+ errorId={a11y.errorId}
63
+ >
64
+ <div class="group relative">
65
+ <div class="absolute inset-y-0 left-3 z-10 flex items-center pointer-events-none text-zinc-400 dark:text-zinc-500">
66
+ <i class={`${icon()} group-focus-within:text-blue-500`} />
67
+ </div>
68
+ <input
69
+ id={a11y.inputId}
70
+ type={dateOnly() ? "date" : "datetime-local"}
71
+ class={`input w-full pl-9 ${disabled() ? "cursor-not-allowed opacity-50" : ""}`}
72
+ value={inputValue()}
73
+ onChange={(e) => props.onChange?.(e.currentTarget.value)}
74
+ disabled={disabled()}
75
+ aria-label={!props.label ? props.placeholder : undefined}
76
+ aria-describedby={a11y.ariaDescribedBy()}
77
+ aria-invalid={!!props.error?.()}
78
+ aria-required={props.required}
79
+ aria-disabled={disabled()}
80
+ />
81
+ </div>
82
+ </InputWrapper>
83
+ );
84
+ };
85
+
86
+ export default DateTimeInput;
@@ -0,0 +1,170 @@
1
+ import { showFileDialog } from "@valentinkolb/stdlib/browser";
2
+ import { img } from "@valentinkolb/stdlib/browser";
3
+ import { Show } from "solid-js";
4
+ import { InputWrapper, createInputA11y } from "./util";
5
+
6
+ type ImageInputProps = {
7
+ label?: string;
8
+ description?: string;
9
+ ariaLabel?: string;
10
+ value?: () => string | null;
11
+ round?: boolean;
12
+ variant?: "default" | "small";
13
+ onChange?: (value: string | null) => void;
14
+ error?: () => string | undefined;
15
+ required?: boolean;
16
+ disabled?: boolean;
17
+ };
18
+
19
+ /**
20
+ * Image input component with file upload and preview
21
+ * @param label - Optional label text
22
+ * @param description - Optional description text
23
+ * @param ariaLabel - Accessibility label (defaults to label if not provided)
24
+ * @param value - Reactive string value getter (base64 or URL, fallback URLs are treated as null)
25
+ * @param onChange - Called when image changes (receives base64 string or null)
26
+ * @param error - Reactive error message getter
27
+ * @param round - Display image in circular shape
28
+ * @param variant - "default" for large preview, "small" for inline compact view
29
+ * @param required - Show required asterisk after label
30
+ * @param disabled - Disable the input
31
+ */
32
+ const ImageInput = (props: ImageInputProps) => {
33
+ const disabled = () => (props.disabled ?? false) || !props.onChange;
34
+ const variant = () => props.variant ?? "default";
35
+ const a11y = createInputA11y({ description: props.description, error: props.error });
36
+
37
+ // Effective value: treat fallback URLs as null (no custom image set)
38
+ const value = () => {
39
+ const val = props.value?.();
40
+ return val && !val.includes("?fallback") ? val : null;
41
+ };
42
+
43
+ const selectImage = () => {
44
+ if (disabled()) return;
45
+ showFileDialog({ accept: ".jpg,.jpeg,.png,.gif,.webp" })
46
+ .then((file) => img.presets.avatar(file))
47
+ .then((image) => props.onChange?.(image));
48
+ };
49
+
50
+ // Small variant - inline compact view (same height as text input)
51
+ if (variant() === "small") {
52
+ return (
53
+ <InputWrapper
54
+ label={props.label}
55
+ description={props.description}
56
+ error={props.error?.()}
57
+ required={props.required}
58
+ inputId={a11y.inputId}
59
+ descriptionId={a11y.descriptionId}
60
+ errorId={a11y.errorId}
61
+ >
62
+ <div class="flex h-9 items-center gap-1" role="group" aria-labelledby={a11y.inputId} aria-describedby={a11y.ariaDescribedBy()}>
63
+ <button
64
+ type="button"
65
+ class={`btn-secondary btn-sm h-9 w-9 shrink-0 overflow-hidden !p-0 ${props.round ? "rounded-full" : "rounded-lg"}`}
66
+ disabled
67
+ aria-hidden="true"
68
+ tabIndex={-1}
69
+ >
70
+ <Show
71
+ when={value()}
72
+ fallback={
73
+ <div class="flex h-full w-full items-center justify-center">
74
+ <i class="ti ti-photo-off opacity-65" aria-hidden="true" />
75
+ </div>
76
+ }
77
+ >
78
+ <img src={value()!} alt={props.label || "Selected image"} class="h-full w-full object-cover" />
79
+ </Show>
80
+ </button>
81
+ <button
82
+ type="button"
83
+ class="btn-secondary btn-sm flex h-9 w-9 items-center justify-center"
84
+ onClick={selectImage}
85
+ aria-label={value() ? "Change image" : "Add image"}
86
+ disabled={disabled()}
87
+ >
88
+ <i class={value() ? "ti ti-edit" : "ti ti-photo-plus"} aria-hidden="true" />
89
+ </button>
90
+ <Show when={value()}>
91
+ <button
92
+ type="button"
93
+ class="btn-secondary btn-sm flex h-9 w-9 items-center justify-center"
94
+ onClick={() => props.onChange?.(null)}
95
+ aria-label="Remove image"
96
+ disabled={disabled()}
97
+ >
98
+ <i class="ti ti-trash" aria-hidden="true" />
99
+ </button>
100
+ </Show>
101
+ </div>
102
+ </InputWrapper>
103
+ );
104
+ }
105
+
106
+ // Default variant - large preview
107
+ return (
108
+ <InputWrapper
109
+ label={props.label}
110
+ description={props.description}
111
+ error={props.error?.()}
112
+ required={props.required}
113
+ inputId={a11y.inputId}
114
+ descriptionId={a11y.descriptionId}
115
+ errorId={a11y.errorId}
116
+ >
117
+ <div class="flex flex-col items-center gap-1" role="group" aria-labelledby={a11y.inputId} aria-describedby={a11y.ariaDescribedBy()}>
118
+ <div
119
+ class={`h-30 w-30 self-center overflow-hidden border-2 border-zinc-200 md:h-50 md:w-50 dark:border-zinc-700 ${
120
+ props.round ? "rounded-full" : "rounded-2xl"
121
+ }`}
122
+ >
123
+ <Show
124
+ when={value()}
125
+ fallback={
126
+ <div class="flex h-full w-full items-center justify-center bg-zinc-100 dark:bg-zinc-800">
127
+ <i class="ti ti-photo-off text-2xl text-zinc-400 dark:text-zinc-600" aria-hidden="true" />
128
+ </div>
129
+ }
130
+ >
131
+ <img
132
+ src={value()!}
133
+ alt={props.label || "Selected image"}
134
+ class="h-full w-full object-cover"
135
+ aria-label={props.ariaLabel || props.label || "Selected image"}
136
+ />
137
+ </Show>
138
+ </div>
139
+
140
+ <div class="mb-4 flex flex-row items-center gap-2 self-center">
141
+ <Show when={value()}>
142
+ <button
143
+ type="button"
144
+ class="btn-simple btn-sm"
145
+ onClick={() => props.onChange?.(null)}
146
+ aria-label="Remove image"
147
+ disabled={disabled()}
148
+ >
149
+ <i class="ti ti-trash" aria-hidden="true" />
150
+ Remove
151
+ </button>
152
+ </Show>
153
+
154
+ <button
155
+ type="button"
156
+ class="btn-simple btn-sm"
157
+ onClick={selectImage}
158
+ aria-label={value() ? "Change image" : "Add image"}
159
+ disabled={disabled()}
160
+ >
161
+ <i class="ti ti-photo-plus" aria-hidden="true" />
162
+ {value() ? "Change" : "Add"}
163
+ </button>
164
+ </div>
165
+ </div>
166
+ </InputWrapper>
167
+ );
168
+ };
169
+
170
+ export default ImageInput;
@@ -0,0 +1,113 @@
1
+ import { InputWrapper, createInputA11y } from "./util";
2
+
3
+ type NumberInputProps = {
4
+ label?: string;
5
+ description?: string;
6
+ placeholder?: string;
7
+ value?: () => number | undefined;
8
+ onChange?: (value: number) => void;
9
+ onInput?: (value: number) => void;
10
+ error?: () => string | undefined;
11
+ max?: number;
12
+ min?: number;
13
+ step?: number;
14
+ required?: boolean;
15
+ disabled?: boolean;
16
+ };
17
+
18
+ /**
19
+ * Number input component with increment/decrement buttons
20
+ * @param label - Optional label text
21
+ * @param description - Optional description text
22
+ * @param placeholder - Placeholder text
23
+ * @param value - Reactive number value getter
24
+ * @param onChange - Called on change event
25
+ * @param onInput - Called on input event
26
+ * @param error - Reactive error message getter
27
+ * @param max - Maximum allowed value (default: Infinity)
28
+ * @param min - Minimum allowed value (default: -Infinity)
29
+ * @param step - Step increment/decrement amount (default: 1)
30
+ * @param required - Show required asterisk after label
31
+ * @param disabled - Disable the input
32
+ */
33
+ const NumberInput = (props: NumberInputProps) => {
34
+ const value = () => props.value?.() ?? 0;
35
+ const max = () => props.max ?? Infinity;
36
+ const min = () => props.min ?? -Infinity;
37
+ const step = () => props.step ?? 1;
38
+ const disabled = () => props.disabled ?? false;
39
+ const a11y = createInputA11y({ description: props.description, error: props.error });
40
+
41
+ const parse = (val: string, applyConstraints: boolean = true) => {
42
+ const parsed = parseInt(val);
43
+ if (isNaN(parsed)) return min();
44
+ return applyConstraints ? Math.max(min(), Math.min(max(), parsed)) : parsed;
45
+ };
46
+
47
+ return (
48
+ <InputWrapper
49
+ label={props.label}
50
+ description={props.description}
51
+ error={props.error?.()}
52
+ required={props.required}
53
+ inputId={a11y.inputId}
54
+ descriptionId={a11y.descriptionId}
55
+ errorId={a11y.errorId}
56
+ >
57
+ <div class={`flex flex-row flex-nowrap gap-3 text-nowrap ${disabled() ? "opacity-50" : ""}`}>
58
+ <button
59
+ type="button"
60
+ class={`input ti ti-minus px-3 cursor-pointer hover:text-primary ${value() <= min() && "opacity-40"}`}
61
+ aria-label="Decrease value"
62
+ onClick={() => {
63
+ const v = Math.max(min(), value() - step());
64
+ props.onChange?.(v);
65
+ props.onInput?.(v);
66
+ }}
67
+ disabled={disabled() || value() <= min()}
68
+ />
69
+ <div class="group relative flex-1">
70
+ <input
71
+ id={a11y.inputId}
72
+ type="number"
73
+ class={`input w-full text-center font-mono font-semibold ${disabled() ? "cursor-not-allowed" : ""}`}
74
+ placeholder={props.placeholder}
75
+ value={value()}
76
+ onChange={(e) => {
77
+ const v = parse(e.currentTarget.value, true);
78
+ props.onChange?.(v);
79
+ e.currentTarget.value = `${v}`;
80
+ }}
81
+ onInput={(e) => {
82
+ const v = parse(e.currentTarget.value, false);
83
+ props.onInput?.(v);
84
+ }}
85
+ disabled={disabled()}
86
+ aria-label={!props.label ? props.placeholder || "Enter number" : undefined}
87
+ aria-describedby={a11y.ariaDescribedBy()}
88
+ aria-invalid={!!props.error?.()}
89
+ aria-required={props.required}
90
+ aria-disabled={disabled()}
91
+ aria-valuemin={min()}
92
+ aria-valuemax={max()}
93
+ aria-valuenow={value()}
94
+ />
95
+ </div>
96
+
97
+ <button
98
+ type="button"
99
+ class={`input ti ti-plus px-3 cursor-pointer hover:text-primary ${value() >= max() && "opacity-40"}`}
100
+ aria-label="Increase value"
101
+ onClick={() => {
102
+ const v = Math.min(max(), value() + step());
103
+ props.onChange?.(v);
104
+ props.onInput?.(v);
105
+ }}
106
+ disabled={disabled() || value() >= max()}
107
+ />
108
+ </div>
109
+ </InputWrapper>
110
+ );
111
+ };
112
+
113
+ export default NumberInput;