@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,468 @@
1
+ import { For, Show, createMemo, type JSX } from "solid-js";
2
+
3
+ export type SidebarRow = {
4
+ id: string;
5
+ label?: string;
6
+ href?: string;
7
+ icon?: string;
8
+ labelIcon?: string;
9
+ meta?: string;
10
+ active?: boolean;
11
+ class?: string;
12
+ content?: JSX.Element;
13
+ actionIcon?: string;
14
+ actionLabel?: string;
15
+ onActionClick?: (event: MouseEvent) => void;
16
+ };
17
+
18
+ export type SidebarSection = {
19
+ title?: string;
20
+ rows: SidebarRow[];
21
+ };
22
+
23
+ export type SidebarTreeNode = {
24
+ id: string;
25
+ label: string;
26
+ icon?: string;
27
+ labelIcon?: string;
28
+ meta?: string;
29
+ active?: boolean;
30
+ href?: string;
31
+ actionIcon?: string;
32
+ actionLabel?: string;
33
+ onActionClick?: (event: MouseEvent, nodeId: string) => void;
34
+ children?: SidebarTreeNode[];
35
+ };
36
+
37
+ export type SidebarTreeSpec = {
38
+ title?: string;
39
+ nodes: SidebarTreeNode[];
40
+ selectedId?: string;
41
+ expandedIds?: string[];
42
+ onToggle?: (nodeId: string) => void;
43
+ onSelect?: (nodeId: string) => void;
44
+ };
45
+
46
+ export type SidebarSpec = {
47
+ header: {
48
+ title: string;
49
+ subtitle?: string;
50
+ icon?: string | JSX.Element;
51
+ settingsHref?: string;
52
+ };
53
+ actions?: SidebarSection[] | SidebarRow[];
54
+ nav?: SidebarSection[] | SidebarRow[];
55
+ tree?: SidebarTreeSpec;
56
+ controls?: JSX.Element;
57
+ footer?: SidebarSection[] | SidebarRow[];
58
+ mobile?: {
59
+ mode?: "auto" | "hidden";
60
+ defaultOpen?: boolean;
61
+ toggleIcon?: "chevron" | "eye";
62
+ include?: Array<"settings" | "actions" | "nav" | "tree" | "controls" | "footer">;
63
+ };
64
+ };
65
+
66
+ type SidebarLayoutProps = {
67
+ render?: "both" | "mobile" | "desktop";
68
+ mobile?: {
69
+ header: JSX.Element;
70
+ items?: JSX.Element;
71
+ body?: JSX.Element;
72
+ defaultOpen?: boolean;
73
+ bodyClass?: string;
74
+ toggleIcon?: "chevron" | "eye";
75
+ };
76
+ desktop: {
77
+ class?: string;
78
+ header: JSX.Element;
79
+ actions?: JSX.Element;
80
+ body?: JSX.Element;
81
+ footer?: JSX.Element;
82
+ };
83
+ };
84
+
85
+ type SidebarFromSpecProps = {
86
+ spec: SidebarSpec;
87
+ render?: "both" | "mobile" | "desktop";
88
+ desktopClass?: string;
89
+ };
90
+
91
+ const normalizeSections = (sections?: SidebarSection[] | SidebarRow[], title?: string): SidebarSection[] => {
92
+ if (!sections || sections.length === 0) return [];
93
+ if ("rows" in sections[0]!) {
94
+ return sections as SidebarSection[];
95
+ }
96
+ return [{ title, rows: sections as SidebarRow[] }];
97
+ };
98
+
99
+ function SidebarRowItem(props: { row: SidebarRow; mobile?: boolean }) {
100
+ if (props.row.content) return <>{props.row.content}</>;
101
+
102
+ const sharedLabel = (
103
+ <>
104
+ <Show when={props.row.icon}>
105
+ <i class={`ti ${props.row.icon} text-sm`} />
106
+ </Show>
107
+ <div class="min-w-0 flex-1 text-left">
108
+ <span class="block truncate">{props.row.label}</span>
109
+ <Show when={props.row.meta}>
110
+ <span class="sidebar-item-meta block truncate">{props.row.meta}</span>
111
+ </Show>
112
+ </div>
113
+ <Show when={props.row.labelIcon}>
114
+ <i class={`ti ${props.row.labelIcon} text-xs text-dimmed`} />
115
+ </Show>
116
+ <Show when={props.row.actionIcon && !props.mobile}>
117
+ <button
118
+ type="button"
119
+ class="sidebar-item-action"
120
+ aria-label={props.row.actionLabel ?? "Row action"}
121
+ onClick={(event) => {
122
+ event.preventDefault();
123
+ event.stopPropagation();
124
+ props.row.onActionClick?.(event);
125
+ }}
126
+ >
127
+ <i class={`ti ${props.row.actionIcon}`} />
128
+ </button>
129
+ </Show>
130
+ </>
131
+ );
132
+
133
+ if (props.mobile) {
134
+ if (props.row.href) {
135
+ return (
136
+ <a
137
+ href={props.row.href}
138
+ class={`btn-input btn-input-sm ${props.row.active ? "border-blue-500/35 bg-blue-50/70 text-blue-700 dark:border-blue-400/40 dark:bg-blue-950/40 dark:text-blue-200" : ""} ${props.row.class ?? ""}`}
139
+ data-row={props.row.id}
140
+ >
141
+ <Show when={props.row.icon}>
142
+ <i class={`ti ${props.row.icon}`} />
143
+ </Show>
144
+ <span>{props.row.label}</span>
145
+ </a>
146
+ );
147
+ }
148
+ return (
149
+ <button
150
+ type="button"
151
+ class={`btn-input btn-input-sm ${props.row.active ? "border-blue-500/35 bg-blue-50/70 text-blue-700 dark:border-blue-400/40 dark:bg-blue-950/40 dark:text-blue-200" : ""} ${props.row.class ?? ""}`}
152
+ data-row={props.row.id}
153
+ >
154
+ <Show when={props.row.icon}>
155
+ <i class={`ti ${props.row.icon}`} />
156
+ </Show>
157
+ <span>{props.row.label}</span>
158
+ </button>
159
+ );
160
+ }
161
+
162
+ if (props.row.href) {
163
+ return (
164
+ <a href={props.row.href} class={`sidebar-item ${props.row.active ? "sidebar-item-active" : ""} ${props.row.class ?? ""}`} data-row={props.row.id}>
165
+ {sharedLabel}
166
+ </a>
167
+ );
168
+ }
169
+
170
+ return (
171
+ <button
172
+ type="button"
173
+ class={`sidebar-item ${props.row.active ? "sidebar-item-active" : ""} ${props.row.class ?? ""}`}
174
+ data-row={props.row.id}
175
+ >
176
+ {sharedLabel}
177
+ </button>
178
+ );
179
+ }
180
+
181
+ function SidebarTree(props: { tree: SidebarTreeSpec; level?: number }) {
182
+ const level = props.level ?? 0;
183
+ const expanded = createMemo(() => new Set(props.tree.expandedIds ?? []));
184
+
185
+ return (
186
+ <div class="sidebar-tree" role={level === 0 ? "tree" : undefined}>
187
+ <For each={props.tree.nodes}>
188
+ {(node) => {
189
+ const hasChildren = () => (node.children?.length ?? 0) > 0;
190
+ const isExpanded = () => expanded().has(node.id);
191
+ const isSelected = () => node.active || props.tree.selectedId === node.id;
192
+ const showLeafIcon = () => !hasChildren() && !!node.icon;
193
+
194
+ return (
195
+ <div class="sidebar-tree-item" role="treeitem" aria-level={level + 1} aria-expanded={hasChildren() ? isExpanded() : undefined}>
196
+ <div class={`sidebar-tree-row ${isSelected() ? "sidebar-item-active" : ""}`} style={`--sidebar-level:${level}`}>
197
+ <button
198
+ type="button"
199
+ class="sidebar-tree-toggle"
200
+ onClick={() => {
201
+ if (!hasChildren()) return;
202
+ props.tree.onToggle?.(node.id);
203
+ }}
204
+ aria-label={hasChildren() ? (isExpanded() ? "Collapse" : "Expand") : undefined}
205
+ >
206
+ <Show when={hasChildren()} fallback={showLeafIcon() ? <i class={`ti ${node.icon} text-xs`} /> : <span class="h-2 w-2 rounded-full bg-zinc-300 dark:bg-zinc-600" />}>
207
+ <i class={`ti ${isExpanded() ? "ti-chevron-down" : "ti-chevron-right"} text-[10px]`} />
208
+ </Show>
209
+ </button>
210
+ <Show when={hasChildren() && node.icon}>
211
+ <i class={`ti ${node.icon} text-xs text-dimmed`} />
212
+ </Show>
213
+ <Show
214
+ when={node.href}
215
+ fallback={
216
+ <button type="button" class="min-w-0 flex-1 truncate text-left" onClick={() => props.tree.onSelect?.(node.id)}>
217
+ {node.label}
218
+ </button>
219
+ }
220
+ >
221
+ <a href={node.href!} class="min-w-0 flex-1 truncate">
222
+ {node.label}
223
+ </a>
224
+ </Show>
225
+ <Show when={node.labelIcon}>
226
+ <i class={`ti ${node.labelIcon} text-xs text-dimmed`} />
227
+ </Show>
228
+ <Show when={node.actionIcon}>
229
+ <button
230
+ type="button"
231
+ class="sidebar-item-action"
232
+ aria-label={node.actionLabel ?? "Row action"}
233
+ onClick={(event) => {
234
+ event.preventDefault();
235
+ event.stopPropagation();
236
+ node.onActionClick?.(event, node.id);
237
+ }}
238
+ >
239
+ <i class={`ti ${node.actionIcon}`} />
240
+ </button>
241
+ </Show>
242
+ </div>
243
+ <Show when={hasChildren() && isExpanded()}>
244
+ <div class="sidebar-tree-children">
245
+ <SidebarTree tree={{ ...props.tree, nodes: node.children ?? [] }} level={level + 1} />
246
+ </div>
247
+ </Show>
248
+ </div>
249
+ );
250
+ }}
251
+ </For>
252
+ </div>
253
+ );
254
+ }
255
+
256
+ export function SidebarFromSpec(props: SidebarFromSpecProps) {
257
+ const include = createMemo(() => new Set(props.spec.mobile?.include ?? ["settings", "actions", "nav", "tree", "controls", "footer"]));
258
+ const mobileMode = props.spec.mobile?.mode ?? "auto";
259
+
260
+ const actionSections = createMemo(() => normalizeSections(props.spec.actions, "Actions"));
261
+ const navSections = createMemo(() => normalizeSections(props.spec.nav, "Navigation"));
262
+ const footerSections = createMemo(() => normalizeSections(props.spec.footer));
263
+
264
+ const desktopHeader = (
265
+ <>
266
+ <Show when={typeof props.spec.header.icon === "string"}>
267
+ <div class="w-6 h-6 rounded bg-blue-500 flex items-center justify-center text-white shrink-0">
268
+ <i class={`ti ${(props.spec.header.icon as string) || "ti-layout-sidebar"} text-xs`} />
269
+ </div>
270
+ </Show>
271
+ <Show when={typeof props.spec.header.icon !== "string" && props.spec.header.icon}>{props.spec.header.icon as JSX.Element}</Show>
272
+ <div class="min-w-0 flex-1">
273
+ <p class="truncate text-sm font-semibold text-primary">{props.spec.header.title}</p>
274
+ <Show when={props.spec.header.subtitle}>
275
+ <p class="text-xs text-dimmed truncate">{props.spec.header.subtitle}</p>
276
+ </Show>
277
+ </div>
278
+ <Show when={props.spec.header.settingsHref}>
279
+ <a href={props.spec.header.settingsHref!} class="p-0.5 text-dimmed hover:text-primary transition-colors shrink-0" title="Settings">
280
+ <i class="ti ti-settings text-xs" />
281
+ </a>
282
+ </Show>
283
+ </>
284
+ );
285
+
286
+ const desktopActions = (
287
+ <>
288
+ <For each={actionSections()}>
289
+ {(section) => (
290
+ <section class="sidebar-section">
291
+ <Show when={section.title}>
292
+ <p class="sidebar-section-title">{section.title}</p>
293
+ </Show>
294
+ <div class="flex flex-col gap-1">
295
+ <For each={section.rows}>{(row) => <SidebarRowItem row={row} />}</For>
296
+ </div>
297
+ </section>
298
+ )}
299
+ </For>
300
+ <For each={navSections()}>
301
+ {(section) => (
302
+ <section class="sidebar-section">
303
+ <Show when={section.title}>
304
+ <p class="sidebar-section-title">{section.title}</p>
305
+ </Show>
306
+ <div class="flex flex-col gap-1">
307
+ <For each={section.rows}>{(row) => <SidebarRowItem row={row} />}</For>
308
+ </div>
309
+ </section>
310
+ )}
311
+ </For>
312
+ </>
313
+ );
314
+
315
+ const desktopBody = (
316
+ <>
317
+ <Show when={props.spec.tree}>
318
+ <section class="sidebar-section">
319
+ <Show when={props.spec.tree?.title}>
320
+ <p class="sidebar-section-title">{props.spec.tree?.title}</p>
321
+ </Show>
322
+ <SidebarTree tree={props.spec.tree!} />
323
+ </section>
324
+ </Show>
325
+ <Show when={props.spec.controls}>
326
+ <section class="sidebar-section">{props.spec.controls}</section>
327
+ </Show>
328
+ </>
329
+ );
330
+
331
+ const desktopFooter = (
332
+ <For each={footerSections()}>
333
+ {(section) => (
334
+ <section class="sidebar-section">
335
+ <Show when={section.title}>
336
+ <p class="sidebar-section-title">{section.title}</p>
337
+ </Show>
338
+ <div class="flex flex-col gap-1">
339
+ <For each={section.rows}>{(row) => <SidebarRowItem row={row} />}</For>
340
+ </div>
341
+ </section>
342
+ )}
343
+ </For>
344
+ );
345
+
346
+ const mobileHeader = (
347
+ <>
348
+ <Show when={typeof props.spec.header.icon === "string"}>
349
+ <div class="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center text-white shrink-0">
350
+ <i class={`ti ${(props.spec.header.icon as string) || "ti-layout-sidebar"} text-sm`} />
351
+ </div>
352
+ </Show>
353
+ <Show when={typeof props.spec.header.icon !== "string" && props.spec.header.icon}>{props.spec.header.icon as JSX.Element}</Show>
354
+ <span class="font-semibold truncate flex-1">{props.spec.header.title}</span>
355
+ </>
356
+ );
357
+
358
+ const mobileItems = (
359
+ <>
360
+ <Show when={include().has("settings") && props.spec.header.settingsHref}>
361
+ <a href={props.spec.header.settingsHref!} class="btn-input btn-input-sm">
362
+ <i class="ti ti-settings" />
363
+ Settings
364
+ </a>
365
+ </Show>
366
+ <Show when={include().has("actions")}>
367
+ <For each={actionSections()}>
368
+ {(section) => <For each={section.rows}>{(row) => <SidebarRowItem row={row} mobile />}</For>}
369
+ </For>
370
+ </Show>
371
+ <Show when={include().has("nav")}>
372
+ <For each={navSections()}>
373
+ {(section) => <For each={section.rows}>{(row) => <SidebarRowItem row={row} mobile />}</For>}
374
+ </For>
375
+ </Show>
376
+ <Show when={include().has("footer")}>
377
+ <For each={footerSections()}>
378
+ {(section) => <For each={section.rows}>{(row) => <SidebarRowItem row={row} mobile />}</For>}
379
+ </For>
380
+ </Show>
381
+ </>
382
+ );
383
+
384
+ const mobileBody = (
385
+ <>
386
+ <Show when={include().has("tree") && props.spec.tree}>
387
+ <section class="sidebar-section">
388
+ <Show when={props.spec.tree?.title}>
389
+ <p class="sidebar-section-title">{props.spec.tree?.title}</p>
390
+ </Show>
391
+ <SidebarTree tree={props.spec.tree!} />
392
+ </section>
393
+ </Show>
394
+ <Show when={include().has("controls") && props.spec.controls}>
395
+ <section class="sidebar-section">{props.spec.controls}</section>
396
+ </Show>
397
+ </>
398
+ );
399
+
400
+ return (
401
+ <SidebarLayout
402
+ render={props.render}
403
+ desktop={{
404
+ class: props.desktopClass,
405
+ header: desktopHeader,
406
+ actions: desktopActions,
407
+ body: desktopBody,
408
+ footer: desktopFooter,
409
+ }}
410
+ mobile={
411
+ mobileMode === "hidden"
412
+ ? undefined
413
+ : {
414
+ defaultOpen: props.spec.mobile?.defaultOpen,
415
+ toggleIcon: props.spec.mobile?.toggleIcon,
416
+ header: mobileHeader,
417
+ items: mobileItems,
418
+ body: mobileBody,
419
+ }
420
+ }
421
+ />
422
+ );
423
+ }
424
+
425
+ export function SidebarLayout(props: SidebarLayoutProps) {
426
+ const renderMode = props.render ?? "both";
427
+ const mobileOpenProps = props.mobile?.defaultOpen ? { open: true } : {};
428
+
429
+ return (
430
+ <>
431
+ <Show when={(renderMode === "both" || renderMode === "mobile") && props.mobile}>
432
+ <nav class="lg:hidden flex flex-col gap-3">
433
+ <details class="group" {...mobileOpenProps}>
434
+ <summary class="sidebar-header cursor-pointer select-none list-none">
435
+ {props.mobile!.header}
436
+ <span class="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-dimmed transition-transform group-open:rotate-180">
437
+ <i class={`ti ${props.mobile?.toggleIcon === "eye" ? "ti-eye" : "ti-chevron-down"} text-sm`} />
438
+ </span>
439
+ </summary>
440
+ <Show when={props.mobile?.items}>
441
+ <div class="mt-2 flex flex-wrap gap-2">{props.mobile?.items}</div>
442
+ </Show>
443
+ <Show when={props.mobile?.body}>
444
+ <div class={`mt-2 ${props.mobile?.bodyClass ?? "max-h-64 overflow-y-auto p-2"}`}>{props.mobile?.body}</div>
445
+ </Show>
446
+ </details>
447
+ </nav>
448
+ </Show>
449
+
450
+ <Show when={renderMode === "both" || renderMode === "desktop"}>
451
+ <aside class={`hidden lg:flex flex-col min-h-0 overflow-y-auto ${props.desktop.class ?? ""}`}>
452
+ <div class="sidebar-header">{props.desktop.header}</div>
453
+ <Show when={props.desktop.actions}>
454
+ <div class="flex flex-col gap-3">{props.desktop.actions}</div>
455
+ </Show>
456
+ <Show when={props.desktop.body}>
457
+ <div class={`sidebar-body ${props.desktop.actions ? "mt-2" : ""}`}>{props.desktop.body}</div>
458
+ </Show>
459
+ <Show when={props.desktop.footer}>
460
+ <div class="sidebar-footer">{props.desktop.footer}</div>
461
+ </Show>
462
+ </aside>
463
+ </Show>
464
+ </>
465
+ );
466
+ }
467
+
468
+ export default SidebarLayout;
@@ -0,0 +1,62 @@
1
+ import type { JSX } from "solid-js";
2
+
3
+ /**
4
+ * Widget container — frames a stack of `Widget*` blocks with a title-bar
5
+ * header. The body is `divide-y` so blocks separate naturally; each block
6
+ * brings its own padding.
7
+ *
8
+ * Designed so Stat / List / Status / Pills blocks can be freely combined
9
+ * vertically — every dashboard widget is a custom composition of those.
10
+ *
11
+ * ```tsx
12
+ * <Widget title="Account requests" icon="ti ti-users" href="/app/accounts">
13
+ * <WidgetStat value={12} label="Open" sub="needs review" />
14
+ * <WidgetList items={[{ icon: "ti ti-user", label: "alice@…" }, …]} />
15
+ * </Widget>
16
+ * ```
17
+ */
18
+ type WidgetProps = {
19
+ title: string;
20
+ icon?: string;
21
+ /** When set, the whole header acts as a link to this URL. */
22
+ href?: string;
23
+ /** Tiny meta string in the header (e.g. "last 24h"). */
24
+ meta?: string;
25
+ children: JSX.Element;
26
+ };
27
+
28
+ const Widget = (props: WidgetProps): JSX.Element => {
29
+ const headerClass = `flex items-center gap-2 px-4 py-2.5 border-b border-zinc-100 dark:border-zinc-800 ${
30
+ props.href ? "hover:bg-zinc-50 dark:hover:bg-zinc-800/40 transition-colors" : ""
31
+ }`;
32
+ const headerInner = (
33
+ <>
34
+ {props.icon ? <i class={`${props.icon} text-dimmed text-sm shrink-0`} /> : null}
35
+ <span class="text-xs font-semibold uppercase tracking-wider text-secondary truncate">
36
+ {props.title}
37
+ </span>
38
+ {/* Right cluster: meta sits next to the chevron (both ml-auto'd
39
+ independently used to center the meta between title and chevron). */}
40
+ {props.meta || props.href ? (
41
+ <div class="ml-auto flex items-center gap-2 shrink-0">
42
+ {props.meta ? <span class="text-[10px] text-dimmed">{props.meta}</span> : null}
43
+ {props.href ? <i class="ti ti-chevron-right text-dimmed text-xs" /> : null}
44
+ </div>
45
+ ) : null}
46
+ </>
47
+ );
48
+ return (
49
+ <div class="paper overflow-hidden flex flex-col h-[25rem]">
50
+ {props.href ? (
51
+ <a href={props.href} class={headerClass}>{headerInner}</a>
52
+ ) : (
53
+ <div class={headerClass}>{headerInner}</div>
54
+ )}
55
+ <div class="flex-1 flex flex-col divide-y divide-zinc-100 dark:divide-zinc-800 min-h-0">
56
+ {props.children}
57
+ </div>
58
+ </div>
59
+ );
60
+ };
61
+
62
+ export default Widget;
@@ -0,0 +1,19 @@
1
+ import type { JSX } from "solid-js/jsx-runtime";
2
+
3
+ type WidgetCardProps = {
4
+ title: string;
5
+ icon: string;
6
+ children: JSX.Element;
7
+ };
8
+
9
+ export default function WidgetCard({ title, icon, children }: WidgetCardProps) {
10
+ return (
11
+ <div class="border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 p-4 h-72 sm:h-80 flex flex-col rounded-2xl">
12
+ <div class="flex items-center gap-2 mb-3 shrink-0">
13
+ <i class={`ti ti-${icon} text-dimmed`} />
14
+ <span class="text-sm font-medium text-primary">{title}</span>
15
+ </div>
16
+ {children}
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,39 @@
1
+ import type { JSX } from "solid-js";
2
+
3
+ /**
4
+ * Hero block — single big centred message that fills the widget body.
5
+ * Use for spotlight content (a quote, a single weather location, an
6
+ * empty-state hint like "All caught up"). Always grows to fill remaining
7
+ * space; content is horizontally + vertically centred.
8
+ *
9
+ * For multi-item or stat-heavy content, prefer the dedicated block types.
10
+ */
11
+ type Tone = "emerald" | "amber" | "red" | "blue" | "zinc";
12
+
13
+ const ICON_TONE: Record<Tone, string> = {
14
+ emerald: "text-emerald-600 dark:text-emerald-400",
15
+ amber: "text-amber-600 dark:text-amber-400",
16
+ red: "text-red-500 dark:text-red-400",
17
+ blue: "text-blue-600 dark:text-blue-400",
18
+ zinc: "text-zinc-500 dark:text-zinc-500",
19
+ };
20
+
21
+ type WidgetHeroProps = {
22
+ title: string;
23
+ subtitle?: string;
24
+ icon?: string;
25
+ tone?: Tone;
26
+ };
27
+
28
+ const WidgetHero = (props: WidgetHeroProps): JSX.Element => {
29
+ const iconColor = () => (props.tone ? ICON_TONE[props.tone] : "text-dimmed");
30
+ return (
31
+ <div class="px-5 py-4 flex-1 flex flex-col items-center justify-center gap-2 text-center">
32
+ {props.icon ? <i class={`${props.icon} ${iconColor()} text-3xl`} /> : null}
33
+ <span class="text-base font-medium text-primary leading-snug">{props.title}</span>
34
+ {props.subtitle ? <span class="text-xs text-dimmed">{props.subtitle}</span> : null}
35
+ </div>
36
+ );
37
+ };
38
+
39
+ export default WidgetHero;
@@ -0,0 +1,84 @@
1
+ import type { JSX } from "solid-js";
2
+
3
+ /**
4
+ * List block inside a Widget — a vertical stack of small rows with optional
5
+ * icon, primary label, sub-text, and trailing meta or chevron when the row
6
+ * acts as a link.
7
+ */
8
+ type Tone = "emerald" | "amber" | "red" | "blue" | "zinc";
9
+
10
+ const ICON_TONE: Record<Tone, string> = {
11
+ emerald: "text-emerald-600 dark:text-emerald-400",
12
+ amber: "text-amber-600 dark:text-amber-400",
13
+ red: "text-red-500 dark:text-red-400",
14
+ blue: "text-blue-600 dark:text-blue-400",
15
+ zinc: "text-zinc-500 dark:text-zinc-500",
16
+ };
17
+
18
+ export type WidgetListItem = {
19
+ icon?: string;
20
+ /** Override the default dimmed icon colour with a tone. */
21
+ iconTone?: Tone;
22
+ label: string;
23
+ sub?: string;
24
+ /** Trailing meta (right-aligned, e.g. timestamp or count). */
25
+ meta?: string;
26
+ href?: string;
27
+ };
28
+
29
+ type WidgetListProps = {
30
+ items: WidgetListItem[];
31
+ /** Shown when `items` is empty. Defaults to `"Nothing here yet."`. */
32
+ emptyMessage?: string;
33
+ /** Fills remaining vertical space; scrolls internally if items overflow. */
34
+ grow?: boolean;
35
+ };
36
+
37
+ const Row = (props: { item: WidgetListItem }): JSX.Element => {
38
+ const rowClass = `flex items-center gap-2.5 px-4 py-2 ${
39
+ props.item.href ? "hover:bg-zinc-50 dark:hover:bg-zinc-800/40 transition-colors" : ""
40
+ }`;
41
+ const inner = (
42
+ <>
43
+ {props.item.icon ? (
44
+ <i
45
+ class={`${props.item.icon} ${
46
+ props.item.iconTone ? ICON_TONE[props.item.iconTone] : "text-dimmed"
47
+ } text-sm shrink-0`}
48
+ />
49
+ ) : null}
50
+ <div class="flex-1 min-w-0 flex flex-col">
51
+ <span class="text-xs font-medium text-primary truncate">{props.item.label}</span>
52
+ {props.item.sub ? <span class="text-[10px] text-dimmed truncate">{props.item.sub}</span> : null}
53
+ </div>
54
+ {props.item.meta ? (
55
+ <span class="text-[10px] text-dimmed shrink-0 tabular-nums">{props.item.meta}</span>
56
+ ) : null}
57
+ {props.item.href ? <i class="ti ti-chevron-right text-dimmed text-[10px] shrink-0" /> : null}
58
+ </>
59
+ );
60
+ return props.item.href ? (
61
+ <a href={props.item.href} class={rowClass}>{inner}</a>
62
+ ) : (
63
+ <div class={rowClass}>{inner}</div>
64
+ );
65
+ };
66
+
67
+ const WidgetList = (props: WidgetListProps): JSX.Element => {
68
+ if (props.items.length === 0) {
69
+ return (
70
+ <div class={`px-4 py-3 text-center ${props.grow ? "flex-1 flex items-center justify-center" : ""}`}>
71
+ <span class="text-[11px] text-dimmed italic">{props.emptyMessage ?? "Nothing here yet."}</span>
72
+ </div>
73
+ );
74
+ }
75
+ return (
76
+ <div class={`flex flex-col ${props.grow ? "flex-1 overflow-y-auto" : ""}`}>
77
+ {props.items.map((item) => (
78
+ <Row item={item} />
79
+ ))}
80
+ </div>
81
+ );
82
+ };
83
+
84
+ export default WidgetList;