@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,118 @@
1
+ type SliderProps = {
2
+ label?: string;
3
+ description?: string;
4
+ value: () => number;
5
+ onChange: (value: number) => void;
6
+ min?: number;
7
+ max?: number;
8
+ step?: number;
9
+ disabled?: boolean;
10
+ showValue?: boolean;
11
+ formatValue?: (value: number) => string;
12
+ /** When true, the track fill originates from the center instead of the left edge. */
13
+ center?: boolean;
14
+ /** Value to reset to on double-click. Defaults to center of range (if center) or min. */
15
+ defaultValue?: number;
16
+ };
17
+
18
+ /**
19
+ * Range slider input component.
20
+ */
21
+ const Slider = ({
22
+ label,
23
+ description,
24
+ value,
25
+ onChange,
26
+ min = 0,
27
+ max = 100,
28
+ step = 1,
29
+ disabled = false,
30
+ showValue = true,
31
+ formatValue = (v) => String(v),
32
+ center = false,
33
+ defaultValue,
34
+ }: SliderProps) => {
35
+ const resetValue = defaultValue ?? (center ? (min + max) / 2 : min);
36
+ const inputId = crypto.randomUUID();
37
+ const descId = description ? `${inputId}-desc` : undefined;
38
+
39
+ const percentage = () => ((value() - min) / (max - min)) * 100;
40
+
41
+ const trackBackground = () => {
42
+ const p = percentage();
43
+ const fill = "var(--slider-fill)";
44
+ const track = "var(--slider-track)";
45
+
46
+ if (center) {
47
+ const lo = Math.min(50, p);
48
+ const hi = Math.max(50, p);
49
+ return `linear-gradient(to right, ${track} 0%, ${track} ${lo}%, ${fill} ${lo}%, ${fill} ${hi}%, ${track} ${hi}%, ${track} 100%)`;
50
+ }
51
+ return `linear-gradient(to right, ${fill} 0%, ${fill} ${p}%, ${track} ${p}%, ${track} 100%)`;
52
+ };
53
+
54
+ return (
55
+ <div class="flex flex-col gap-1 slider-track-colors" classList={{ "opacity-50": disabled }}>
56
+ {(label || showValue) && (
57
+ <div class="flex items-center justify-between text-xs">
58
+ {label && (
59
+ <label for={inputId} class="text-secondary">
60
+ {label}
61
+ </label>
62
+ )}
63
+ {showValue && <span class="text-dimmed tabular-nums">{formatValue(value())}</span>}
64
+ </div>
65
+ )}
66
+ {description && (
67
+ <p id={descId} class="text-xs text-dimmed -mt-0.5">
68
+ {description}
69
+ </p>
70
+ )}
71
+ <input
72
+ id={inputId}
73
+ type="range"
74
+ min={min}
75
+ max={max}
76
+ step={step}
77
+ value={value()}
78
+ onInput={(e) => onChange(Number(e.currentTarget.value))}
79
+ onDblClick={() => onChange(resetValue)}
80
+ disabled={disabled}
81
+ aria-describedby={descId}
82
+ class="w-full h-1.5 appearance-none cursor-pointer
83
+ rounded-full
84
+ [&::-webkit-slider-thumb]:appearance-none
85
+ [&::-webkit-slider-thumb]:w-3.5
86
+ [&::-webkit-slider-thumb]:h-3.5
87
+ [&::-webkit-slider-thumb]:cursor-pointer
88
+ [&::-webkit-slider-thumb]:transition-transform
89
+ [&::-webkit-slider-thumb]:rounded-full
90
+ [&::-webkit-slider-thumb]:bg-blue-500
91
+ [&::-webkit-slider-thumb]:dark:bg-blue-400
92
+ [&::-webkit-slider-thumb]:shadow-sm
93
+ [&::-webkit-slider-thumb]:hover:scale-110
94
+ ]:rounded-none
95
+ ]:bg-(--slider-fill)
96
+ [&::-moz-range-thumb]:w-3.5
97
+ [&::-moz-range-thumb]:h-3.5
98
+ [&::-moz-range-thumb]:border-0
99
+ [&::-moz-range-thumb]:cursor-pointer
100
+ [&::-moz-range-thumb]:rounded-full
101
+ [&::-moz-range-thumb]:bg-blue-500
102
+ [&::-moz-range-thumb]:dark:bg-blue-400
103
+ ]:rounded-none
104
+ ]:bg-(--slider-fill)
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
109
+ disabled:cursor-not-allowed
110
+ disabled:[&::-webkit-slider-thumb]:bg-zinc-400
111
+ disabled:[&::-moz-range-thumb]:bg-zinc-400"
112
+ style={{ background: trackBackground() }}
113
+ />
114
+ </div>
115
+ );
116
+ };
117
+
118
+ export default Slider;
@@ -0,0 +1,62 @@
1
+ import type { SwitchInputProps } from "./types";
2
+
3
+ /**
4
+ * Toggle switch component - accessible via hidden checkbox
5
+ */
6
+ const Switch = ({ label, value, onChange, disabled = false }: SwitchInputProps) => {
7
+ const inputId = crypto.randomUUID();
8
+
9
+ return (
10
+ <label
11
+ for={inputId}
12
+ class={`inline-flex items-center gap-2 select-none ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
13
+ >
14
+ {/* Hidden checkbox for accessibility */}
15
+ <input
16
+ id={inputId}
17
+ type="checkbox"
18
+ checked={value?.() || false}
19
+ onChange={(e) => onChange?.(e.target.checked)}
20
+ disabled={disabled}
21
+ class="sr-only peer"
22
+ />
23
+ {/* Visual switch track */}
24
+ <span
25
+ class={`
26
+ relative transition-colors
27
+ w-9 h-5 rounded-full
28
+
29
+ bg-zinc-200 dark:bg-zinc-600/40
30
+ peer-checked:bg-blue-500
31
+
32
+ peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2
33
+
34
+ peer-disabled:opacity-50
35
+ `}
36
+ >
37
+ {/* Switch knob */}
38
+ <span
39
+ class={`
40
+ absolute transition-transform flex items-center justify-center
41
+ top-0.5 left-0.5 w-4 h-4 rounded-full
42
+
43
+ bg-white shadow-sm
44
+
45
+ `}
46
+ classList={{
47
+ "translate-x-4": value?.(),
48
+ "": value?.(),
49
+ }}
50
+ >
51
+ {/* Checkmark icon in terminal mode when checked */}
52
+ <i class={`ti ti-check hidden text-[8px] leading-none ${value?.() ? " " : "text-transparent"}`} />
53
+ </span>
54
+ </span>
55
+ {label && <span class="text-xs text-secondary select-none">{label}</span>}
56
+ </label>
57
+ );
58
+ };
59
+
60
+ export { Switch };
61
+ export const SwitchInput = Switch;
62
+ export default Switch;
@@ -0,0 +1,115 @@
1
+ import { InputWrapper, createInputA11y } from "./util";
2
+
3
+ type TagsInputProps = {
4
+ label?: string;
5
+ description?: string;
6
+ placeholder?: string;
7
+ icon?: string;
8
+ activeIcon?: string;
9
+ value?: () => string[];
10
+ onChange?: (tags: string[]) => void;
11
+ error?: () => string | undefined;
12
+ required?: boolean;
13
+ disabled?: boolean;
14
+ };
15
+
16
+ const TagsInput = (props: TagsInputProps) => {
17
+ const placeholder = () => props.placeholder ?? "Tags (e.g. Tag 1, Tag 2,...)";
18
+ const icon = () => props.icon ?? "ti ti-tag";
19
+ const activeIcon = () => props.activeIcon ?? "ti ti-pencil";
20
+ const value = () => props.value?.() ?? [];
21
+ const disabled = () => props.disabled ?? false;
22
+ const a11y = createInputA11y({ description: props.description, error: props.error });
23
+ const announcementId = crypto.randomUUID();
24
+
25
+ const normalizeTag = (value: string) => value.replace(/\s+/g, " ").trim();
26
+
27
+ const escapeHtml = (value: string) =>
28
+ value
29
+ .replaceAll("&", "&amp;")
30
+ .replaceAll("<", "&lt;")
31
+ .replaceAll(">", "&gt;")
32
+ .replaceAll('"', "&quot;")
33
+ .replaceAll("'", "&#39;");
34
+
35
+ const renderTags = (tags: string[]) => {
36
+ if (tags.length === 0) return `<span class="text-zinc-400 dark:text-zinc-500">${placeholder()}</span>`;
37
+ return `<span contenteditable="false" class="flex flex-wrap items-center gap-1 pointer-events-none">${tags
38
+ .map(
39
+ (tag) =>
40
+ `<span class="inline-flex max-w-37.5 shrink-0 items-center overflow-hidden text-ellipsis whitespace-nowrap rounded px-1.5 text-xs leading-5 bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300">${escapeHtml(tag.trim())}</span>`,
41
+ )
42
+ .join("")}</span>`;
43
+ };
44
+
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="group relative flex">
56
+ <div class={`absolute left-3 inset-y-0 items-center z-10 flex pointer-events-none text-zinc-400 dark:text-zinc-500`}>
57
+ <i class={`${icon()} group-focus-within:hidden`} />
58
+ <i class={`${activeIcon()} hidden text-blue-500 group-focus-within:block`} />
59
+ </div>
60
+ <div
61
+ contentEditable={!disabled()}
62
+ id={a11y.inputId}
63
+ class={`input w-full pl-9 outline-none ${disabled() ? "cursor-not-allowed opacity-50" : "cursor-text"}`}
64
+ role="textbox"
65
+ aria-multiline="false"
66
+ aria-label={!props.label ? placeholder() || "Enter tags" : undefined}
67
+ aria-describedby={a11y.ariaDescribedBy()}
68
+ aria-invalid={!!props.error?.()}
69
+ aria-required={props.required}
70
+ aria-disabled={disabled()}
71
+ aria-placeholder={placeholder()}
72
+ onFocus={(e) => {
73
+ if (disabled()) return;
74
+ const currentTags = value();
75
+ e.currentTarget.textContent = currentTags.join(", ");
76
+ const sel = getSelection();
77
+ sel?.selectAllChildren(e.currentTarget);
78
+ sel?.collapseToEnd();
79
+ }}
80
+ onBlur={(e) => {
81
+ if (disabled()) return;
82
+ const oldTags = value();
83
+ const newTags = (e.currentTarget.textContent || "")
84
+ .split(",")
85
+ .map(normalizeTag)
86
+ .filter(Boolean)
87
+ .filter((tag, index, self) => self.indexOf(tag) === index);
88
+
89
+ const added = newTags.filter((t) => !oldTags.includes(t));
90
+ const removed = oldTags.filter((t) => !newTags.includes(t));
91
+
92
+ if (added.length > 0 || removed.length > 0) {
93
+ const announcement = document.getElementById(announcementId);
94
+ if (announcement) {
95
+ let message = "";
96
+ if (added.length > 0) message += `Tags added: ${added.join(", ")}. `;
97
+ if (removed.length > 0) message += `Tags removed: ${removed.join(", ")}.`;
98
+ announcement.textContent = message;
99
+ }
100
+ }
101
+
102
+ props.onChange?.(newTags);
103
+ e.currentTarget.innerHTML = renderTags(newTags);
104
+ }}
105
+ onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), e.currentTarget.blur())}
106
+ innerHTML={renderTags(value())}
107
+ />
108
+
109
+ <div id={announcementId} class="sr-only" role="status" aria-live="polite" aria-atomic="true" />
110
+ </div>
111
+ </InputWrapper>
112
+ );
113
+ };
114
+
115
+ export default TagsInput;
@@ -0,0 +1,160 @@
1
+ import { createSignal } from "solid-js";
2
+ import { InputWrapper, createInputA11y } from "./util";
3
+
4
+ type TextInputProps = {
5
+ name?: string;
6
+ label?: string;
7
+ description?: string;
8
+ placeholder?: string;
9
+ ariaLabel?: string;
10
+ type?: "text" | "search" | "email" | "url" | "tel";
11
+ icon?: string;
12
+ activeIcon?: string;
13
+ value?: () => string | undefined | null;
14
+ onChange?: (value: string) => void;
15
+ onInput?: (value: string) => void;
16
+ clearable?: boolean;
17
+ onClear?: () => void;
18
+ clearLabel?: string;
19
+ error?: () => string | undefined;
20
+ multiline?: boolean;
21
+ required?: boolean;
22
+ disabled?: boolean;
23
+ password?: boolean;
24
+ /**
25
+ * Enable markdown mode.
26
+ * When true, automatically enables multiline mode and sets default icon to markdown.
27
+ */
28
+ markdown?: boolean;
29
+ /**
30
+ * Called when Enter is pressed (without Shift/Cmd) in multiline mode.
31
+ * Useful for submitting forms with Enter while keeping Shift+Enter for newlines.
32
+ */
33
+ onSubmit?: () => void;
34
+ /** Approximate visible lines for multiline mode. Overrides default height. */
35
+ lines?: number;
36
+ };
37
+
38
+ /**
39
+ * Text input component with optional multiline support
40
+ * @param label - Optional label text
41
+ * @param description - Optional description text
42
+ * @param placeholder - Placeholder text
43
+ * @param icon - Icon shown when not focused
44
+ * @param activeIcon - Icon shown when focused
45
+ * @param value - Reactive value getter
46
+ * @param onChange - Called on change event
47
+ * @param onInput - Called on input event
48
+ * @param error - Reactive error message getter
49
+ * @param multiline - Enable textarea mode
50
+ * @param required - Show required asterisk after label
51
+ * @param disabled - Disable the input
52
+ * @param markdown - Enable markdown mode (implies multiline, shows markdown icon)
53
+ */
54
+ const TextInput = (props: TextInputProps) => {
55
+ const markdown = () => props.markdown ?? false;
56
+ const icon = () => props.icon ?? (markdown() ? "ti ti-markdown" : "ti ti-cursor-text");
57
+ const activeIcon = () => props.activeIcon ?? "ti ti-pencil";
58
+ const multiline = () => props.multiline ?? markdown(); // markdown implies multiline
59
+ const disabled = () => props.disabled ?? false;
60
+ const canClear = () => props.clearable && !multiline() && !props.password && !disabled();
61
+ const currentValue = () => props.value?.() ?? "";
62
+ const hasValue = () => currentValue().length > 0;
63
+ const [showPassword, setShowPassword] = createSignal(false);
64
+ const a11y = createInputA11y({ description: props.description, error: props.error, inputId: props.name });
65
+
66
+ const handleClear = () => {
67
+ if (props.onClear) {
68
+ props.onClear();
69
+ return;
70
+ }
71
+ props.onInput?.("");
72
+ props.onChange?.("");
73
+ };
74
+
75
+ return (
76
+ <InputWrapper
77
+ label={props.label}
78
+ description={props.description}
79
+ error={props.error?.()}
80
+ required={props.required}
81
+ inputId={a11y.inputId}
82
+ descriptionId={a11y.descriptionId}
83
+ errorId={a11y.errorId}
84
+ >
85
+ <div class="group relative flex">
86
+ <div
87
+ class={`absolute left-3 z-10 flex pointer-events-none text-zinc-400 dark:text-zinc-500 ${
88
+ multiline() ? "top-2.5" : "inset-y-0 items-center"
89
+ }`}
90
+ >
91
+ <i class={`${icon()} group-focus-within:hidden`} />
92
+ <i class={`${activeIcon()} hidden text-blue-500 group-focus-within:block`} />
93
+ </div>
94
+ {multiline() ? (
95
+ <textarea
96
+ id={a11y.inputId}
97
+ name={props.name}
98
+ class={`input w-full pl-9 ${disabled() ? "cursor-not-allowed opacity-50" : ""}`}
99
+ style={props.lines ? `min-height: ${props.lines * 1.5}em; max-height: ${Math.max(props.lines * 1.5, 20)}em` : "min-height: 3.75rem; height: 5rem; max-height: 12.5rem"}
100
+ placeholder={props.placeholder}
101
+ value={props.value?.() ?? ""}
102
+ onChange={(e) => props.onChange?.(e.target.value)}
103
+ onInput={(e) => props.onInput?.(e.target.value)}
104
+ onKeyDown={(e) => {
105
+ if (props.onSubmit && e.key === "Enter" && !e.shiftKey && !e.metaKey) {
106
+ e.preventDefault();
107
+ props.onSubmit();
108
+ }
109
+ }}
110
+ disabled={disabled()}
111
+ aria-label={!props.label ? (props.ariaLabel ?? props.placeholder) : undefined}
112
+ aria-describedby={a11y.ariaDescribedBy()}
113
+ aria-invalid={!!props.error?.()}
114
+ aria-required={props.required}
115
+ aria-disabled={disabled()}
116
+ />
117
+ ) : (
118
+ <input
119
+ id={a11y.inputId}
120
+ name={props.name}
121
+ type={props.password && !showPassword() ? "password" : (props.type ?? "text")}
122
+ class={`input w-full pl-9 ${props.password || canClear() ? "pr-9" : ""} ${disabled() ? "cursor-not-allowed opacity-50" : ""}`}
123
+ placeholder={props.placeholder}
124
+ value={currentValue()}
125
+ onChange={(e) => props.onChange?.(e.target.value)}
126
+ onInput={(e) => props.onInput?.(e.target.value)}
127
+ disabled={disabled()}
128
+ aria-label={!props.label ? (props.ariaLabel ?? props.placeholder) : undefined}
129
+ aria-describedby={a11y.ariaDescribedBy()}
130
+ aria-invalid={!!props.error?.()}
131
+ aria-required={props.required}
132
+ aria-disabled={disabled()}
133
+ />
134
+ )}
135
+ {canClear() && hasValue() && (
136
+ <button
137
+ type="button"
138
+ class="absolute inset-y-0 right-3 flex items-center text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300"
139
+ onClick={handleClear}
140
+ aria-label={props.clearLabel ?? "Clear input"}
141
+ >
142
+ <i class="ti ti-x" />
143
+ </button>
144
+ )}
145
+ {props.password && !multiline() && (
146
+ <button
147
+ type="button"
148
+ class="absolute inset-y-0 right-3 flex items-center text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300"
149
+ onClick={() => setShowPassword(!showPassword())}
150
+ tabIndex={-1}
151
+ >
152
+ <i class={showPassword() ? "ti ti-eye-off" : "ti ti-eye"} />
153
+ </button>
154
+ )}
155
+ </div>
156
+ </InputWrapper>
157
+ );
158
+ };
159
+
160
+ export default TextInput;
@@ -0,0 +1,13 @@
1
+ export { default as TextInput } from "./TextInput";
2
+ export { default as NumberInput } from "./NumberInput";
3
+ export { Checkbox, CheckboxInput } from "./Checkbox";
4
+ export { Select, SelectInput } from "./Select";
5
+ export { default as SelectChip } from "./SelectChip";
6
+ export { Switch, SwitchInput } from "./Switch";
7
+ export { default as DateTimeInput } from "./DateTimeInput";
8
+ export { default as SegmentedControl } from "./SegmentedControl";
9
+ export { default as ColorInput } from "./ColorInput";
10
+ export { default as TagsInput } from "./TagsInput";
11
+ export { default as PinInput } from "./PinInput";
12
+ export { default as ImageInput } from "./ImageInput";
13
+ export { default as Slider } from "./Slider";
@@ -0,0 +1,42 @@
1
+ import type { JSX } from "solid-js";
2
+
3
+ /**
4
+ * Shared input component prop types
5
+ */
6
+
7
+ /** Base props shared by all input components */
8
+ export type BaseInputProps = {
9
+ label?: string | JSX.Element;
10
+ description?: string;
11
+ error?: () => string | undefined;
12
+ required?: boolean;
13
+ disabled?: boolean;
14
+ };
15
+
16
+ /** Props for checkbox/toggle inputs */
17
+ export type CheckboxInputProps = BaseInputProps & {
18
+ value?: () => boolean | undefined;
19
+ onChange?: (checked: boolean) => void;
20
+ };
21
+
22
+ /** Props for switch/toggle inputs */
23
+ export type SwitchInputProps = {
24
+ label?: string;
25
+ value?: () => boolean;
26
+ onChange?: (checked: boolean) => void;
27
+ disabled?: boolean;
28
+ };
29
+
30
+ /** Props for color input */
31
+ export type ColorInputProps = BaseInputProps & {
32
+ value?: () => string | undefined;
33
+ onChange?: (value: string) => void;
34
+ /** Compact mode - just shows color swatch */
35
+ compact?: boolean;
36
+ /** Show a transparent toggle button inside the input */
37
+ transparent?: boolean;
38
+ /** Whether transparent is currently active */
39
+ isTransparent?: () => boolean;
40
+ /** Called when transparent toggle changes */
41
+ onTransparentChange?: (value: boolean) => void;
42
+ };
@@ -0,0 +1,105 @@
1
+ import { Show, createUniqueId, type Accessor, type JSX } from "solid-js";
2
+
3
+ export type InputA11y = {
4
+ inputId: string;
5
+ descriptionId: string | undefined;
6
+ errorId: string;
7
+ ariaDescribedBy: Accessor<string | undefined>;
8
+ };
9
+
10
+ /**
11
+ * Module-level fallback counter for `createUniqueId`.
12
+ *
13
+ * Solid's `createUniqueId` requires a hydrating render context. The current
14
+ * @valentinkolb/ssr release renders islands via `renderToString` (sync, NOT
15
+ * hydrating), which throws "getNextContextId cannot be used under
16
+ * non-hydrating context" when an island contains TextInput / similar inputs.
17
+ *
18
+ * Islands are re-mounted client-side anyway (no SSR-hydration matchup for
19
+ * the island content), so a stable-but-arbitrary id is fine for the SSR
20
+ * pass — client mount runs `createUniqueId` again with a real context.
21
+ */
22
+ let fallbackIdCounter = 0;
23
+ const safeUniqueId = (): string => {
24
+ try {
25
+ return createUniqueId();
26
+ } catch {
27
+ fallbackIdCounter += 1;
28
+ return `ssr-${fallbackIdCounter}`;
29
+ }
30
+ };
31
+
32
+ export const createInputA11y = (props: {
33
+ description?: string | JSX.Element;
34
+ error?: () => string | undefined;
35
+ inputId?: string;
36
+ }): InputA11y => {
37
+ const baseId = safeUniqueId();
38
+ const inputId = props.inputId ?? `input-${baseId}`;
39
+ const descriptionId = props.description ? `${inputId}-desc` : undefined;
40
+ const errorId = `${inputId}-error`;
41
+
42
+ return {
43
+ inputId,
44
+ descriptionId,
45
+ errorId,
46
+ ariaDescribedBy: () => {
47
+ const parts: string[] = [];
48
+ if (descriptionId) parts.push(descriptionId);
49
+ if (props.error?.()) parts.push(errorId);
50
+ return parts.length > 0 ? parts.join(" ") : undefined;
51
+ },
52
+ };
53
+ };
54
+
55
+ /**
56
+ * Props for InputWrapper component
57
+ */
58
+ export type InputWrapperProps = {
59
+ label?: string | JSX.Element;
60
+ description?: string | JSX.Element;
61
+ error?: string | undefined;
62
+ required?: boolean;
63
+ inputId: string;
64
+ descriptionId?: string;
65
+ errorId?: string;
66
+ children: JSX.Element;
67
+ };
68
+
69
+ /**
70
+ * Shared wrapper for labeled inputs. Input IDs and aria wiring are created outside
71
+ * the wrapper so the input subtree stays structurally stable during reactive updates.
72
+ */
73
+ export const InputWrapper = (props: InputWrapperProps) => {
74
+ return (
75
+ <div class="flex flex-col gap-1">
76
+ <Show when={props.label || props.description}>
77
+ <label for={props.inputId}>
78
+ <Show when={props.label}>
79
+ <p class="block text-sm font-medium">
80
+ {props.label}
81
+ <Show when={props.required}>
82
+ <span class="ml-0.5 text-red-500" aria-hidden="true">
83
+ *
84
+ </span>
85
+ </Show>
86
+ </p>
87
+ </Show>
88
+ <Show when={props.description}>
89
+ <p id={props.descriptionId} class="text-dimmed block text-xs">
90
+ {props.description}
91
+ </p>
92
+ </Show>
93
+ </label>
94
+ </Show>
95
+
96
+ {props.children}
97
+
98
+ <Show when={props.error}>
99
+ <p id={props.errorId} class="text-xs text-red-500" role="alert" aria-live="polite">
100
+ {props.error}
101
+ </p>
102
+ </Show>
103
+ </div>
104
+ );
105
+ };
@@ -0,0 +1,28 @@
1
+ type AvatarSize = "sm" | "md" | "lg" | "xl";
2
+
3
+ type AvatarProps = {
4
+ username: string;
5
+ size?: AvatarSize;
6
+ class?: string;
7
+ };
8
+
9
+ const SIZE_CLASSES: Record<AvatarSize, string> = {
10
+ sm: "h-8 w-8 text-xs",
11
+ md: "h-10 w-10 text-sm",
12
+ lg: "h-16 w-16 text-lg",
13
+ xl: "h-20 w-20 text-xl",
14
+ };
15
+
16
+ /** Displays a user avatar with initials. */
17
+ export default function Avatar(props: AvatarProps) {
18
+ const sizeClass = SIZE_CLASSES[props.size ?? "md"];
19
+ const initials = props.username.slice(0, 2).toUpperCase();
20
+
21
+ return (
22
+ <div
23
+ class={`flex shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700 font-semibold text-zinc-600 dark:text-zinc-300 ${sizeClass} ${props.class ?? ""}`}
24
+ >
25
+ {initials}
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,36 @@
1
+ import type { BaseGroup } from "../../contracts/shared";
2
+
3
+ type GroupViewProps = {
4
+ group: BaseGroup;
5
+ canManage?: boolean;
6
+ };
7
+
8
+ export default function GroupView(props: GroupViewProps) {
9
+ return (
10
+ <div class="flex items-start gap-3 min-w-0">
11
+ <div class="flex shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 h-9 w-9">
12
+ <i class="ti ti-users-group text-base" />
13
+ </div>
14
+ <div class="flex flex-col gap-0.5 min-w-0">
15
+ <div class="flex items-center gap-2">
16
+ <span class="text-sm font-medium text-primary truncate">{props.group.name}</span>
17
+ {props.group.gidnumber && (
18
+ <span class="tag bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300 shrink-0">
19
+ POSIX {props.group.gidnumber}
20
+ </span>
21
+ )}
22
+ {props.canManage && (
23
+ <span
24
+ class="tag bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 shrink-0"
25
+ title="You can manage this group"
26
+ >
27
+ <i class="ti ti-shield text-xs" />
28
+ MANAGER
29
+ </span>
30
+ )}
31
+ </div>
32
+ <span class="text-xs text-dimmed truncate">{props.group.description || "No description"}</span>
33
+ </div>
34
+ </div>
35
+ );
36
+ }