@valentinkolb/cloud 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +116 -13
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/notifications/index.ts +82 -11
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +79 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +58 -0
  92. package/src/shared/redirect.ts +56 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Toast — transient bottom-right notifications, Mantine-style.
3
+ *
4
+ * Sits next to `prompts` as the platform's lightweight messaging
5
+ * surface: where `prompts.alert/confirm/form` are blocking modals,
6
+ * `toast()` is fire-and-forget feedback that the user doesn't have
7
+ * to dismiss.
8
+ *
9
+ * API shape
10
+ * ---------
11
+ * The first positional arg is the **description** (the body line);
12
+ * the title defaults to the variant name ("Info" / "Success" /
13
+ * "Error") and can be overridden via `options.title`. Rationale:
14
+ * for the 95 % case ("just tell the user something happened") the
15
+ * default title is fine, and the desc is what carries the actual
16
+ * information. Spelling out a custom title is opt-in.
17
+ *
18
+ * Visual language
19
+ * ---------------
20
+ * White card body on a soft float, neutral zinc title, dimmed gray
21
+ * description. The variant signal lives in a leading soft-tinted disc
22
+ * with a colour-matched icon (the elevated, low-shout treatment from
23
+ * the redesign — a faint wash rather than a loud saturated fill):
24
+ * - `default` → soft blue disc with info icon
25
+ * - `success` → soft green disc with check icon
26
+ * - `error` → soft red disc with X icon
27
+ *
28
+ * Dark mode mirrors with `zinc-900` body, lighter zinc text; the disc
29
+ * tint + icon colour shift to the lighter 400-tones so the variant
30
+ * still reads at a glance against the dark surface.
31
+ *
32
+ * Every toast in the stack renders at the same fixed width
33
+ * (`TOAST_WIDTH_CLASS`) so they line up neatly when stacked.
34
+ *
35
+ * Usage
36
+ * -----
37
+ * ```ts
38
+ * import { toast } from "@valentinkolb/cloud/ui";
39
+ *
40
+ * toast("All changes synced"); // title "Info"
41
+ * toast("All changes synced", { title: "Saved" });
42
+ * toast.success("Untitled-3 created"); // title "Success"
43
+ * toast.error("Network unreachable"); // title "Error"
44
+ * toast.error("Network unreachable", { title: "Bummer!", duration: 5000 });
45
+ *
46
+ * const t = toast("0%", { title: "Uploading", duration: 0 });
47
+ * t.update("50%");
48
+ * t.update("Everything fine", { variant: "success", title: "Done", duration: 2000 });
49
+ *
50
+ * toast.dismissAll();
51
+ * ```
52
+ *
53
+ * SSR-safe: every entry point bails when `document` is unavailable.
54
+ */
55
+
56
+ export type ToastVariant = "default" | "success" | "error";
57
+
58
+ export type ToastOptions = {
59
+ /** Visual style. Default `"default"` (blue left-bar). */
60
+ variant?: ToastVariant;
61
+ /** Auto-dismiss after this many ms. Default `3000`. `0` = sticky
62
+ * (only manual `t.dismiss()` removes it). */
63
+ duration?: number;
64
+ /** `ti-…` icon class to override the variant default. Only
65
+ * applies to `success` / `error` variants — the `default`
66
+ * variant doesn't render an icon. */
67
+ iconClass?: string;
68
+ /** Override the variant default title (`"Info"` / `"Success"` /
69
+ * `"Error"`). Pass any string — `""` renders an empty title row. */
70
+ title?: string;
71
+ };
72
+
73
+ export type ToastHandle = {
74
+ /** Animate out and remove. No-op if already dismissed. */
75
+ dismiss: () => void;
76
+ /**
77
+ * Mutate the visible toast in place. Only present option keys
78
+ * change; missing keys leave the existing values alone.
79
+ * - `update("X")` → desc becomes "X", everything else unchanged
80
+ * - `update("X", { title: "Saved" })` → desc + title both update
81
+ * - `update("X", { variant: "success" })` → swaps the leading
82
+ * element (bar ↔ circle) AND swaps the title to the new
83
+ * variant default unless `title` is explicitly passed
84
+ *
85
+ * The auto-dismiss timer resets to the (new or existing)
86
+ * `duration` so a near-expired toast doesn't disappear right
87
+ * after a fresh update.
88
+ */
89
+ update: (description: string, options?: ToastOptions) => void;
90
+ };
91
+
92
+ export interface ToastFn {
93
+ (description: string, options?: ToastOptions): ToastHandle;
94
+ success: (description: string, options?: Omit<ToastOptions, "variant">) => ToastHandle;
95
+ error: (description: string, options?: Omit<ToastOptions, "variant">) => ToastHandle;
96
+ /** Dismiss every currently visible toast. Useful for route
97
+ * changes / major UI transitions where stale notifications are
98
+ * confusing. */
99
+ dismissAll: () => void;
100
+ }
101
+
102
+ // =============================================================================
103
+ // Constants
104
+ // =============================================================================
105
+
106
+ const DEFAULT_DURATION_MS = 3000;
107
+ const MAX_VISIBLE_TOASTS = 5;
108
+ const ANIMATION_MS = 200;
109
+ const CONTAINER_ID = "ui-toast-container";
110
+
111
+ /** Fixed width for every toast — all toasts in the stack line up at
112
+ * this exact width regardless of content length. `w-80` = 20 rem ≈
113
+ * 320 px; intentionally narrower than Mantine's default so toasts
114
+ * don't dominate the right rail. To make this configurable later,
115
+ * lift to an option. */
116
+ const TOAST_WIDTH_CLASS = "w-80";
117
+
118
+ /** Per-variant rendering recipe. The lead element is a 36 px soft-tinted
119
+ * disc with a colour-matched icon — a faint wash (`/10`–`/15` alpha)
120
+ * rather than a saturated fill, so it reads as elevated rather than
121
+ * shouty. Tint + icon colour shift to the 400-tones in dark mode so
122
+ * the variant signal survives the theme flip. */
123
+ type VariantStyle = {
124
+ /** Tailwind classes for the disc's soft-tinted background (light + dark). */
125
+ circleBgClass: string;
126
+ /** Tailwind classes for the icon colour inside the disc (light + dark). */
127
+ iconColorClass: string;
128
+ /** Default `ti-…` class for the disc's icon. Overridable via
129
+ * `options.iconClass`. */
130
+ iconClass: string;
131
+ /** Default title shown when `options.title` is not set. */
132
+ defaultTitle: string;
133
+ };
134
+
135
+ const VARIANT_STYLES: Record<ToastVariant, VariantStyle> = {
136
+ default: {
137
+ circleBgClass: "bg-blue-500/10 dark:bg-blue-400/15",
138
+ iconColorClass: "text-blue-600 dark:text-blue-400",
139
+ iconClass: "ti-info-circle",
140
+ defaultTitle: "Info",
141
+ },
142
+ success: {
143
+ circleBgClass: "bg-green-500/15 dark:bg-green-400/15",
144
+ iconColorClass: "text-green-600 dark:text-green-400",
145
+ iconClass: "ti-check",
146
+ defaultTitle: "Success",
147
+ },
148
+ error: {
149
+ circleBgClass: "bg-red-500/15 dark:bg-red-400/15",
150
+ iconColorClass: "text-red-600 dark:text-red-400",
151
+ iconClass: "ti-x",
152
+ defaultTitle: "Error",
153
+ },
154
+ };
155
+
156
+ const splitClasses = (cls: string): string[] => cls.split(/\s+/).filter(Boolean);
157
+
158
+ // All currently-mounted toasts. Used for `dismissAll`.
159
+ const liveToasts = new Set<ToastHandle>();
160
+
161
+ // =============================================================================
162
+ // Container
163
+ // =============================================================================
164
+
165
+ /** Lazily-mount the fixed-position container. Idempotent. */
166
+ const ensureContainer = (): HTMLElement | null => {
167
+ if (typeof document === "undefined") return null;
168
+ let container = document.getElementById(CONTAINER_ID);
169
+ if (container) return container;
170
+ container = document.createElement("div");
171
+ container.id = CONTAINER_ID;
172
+ container.className =
173
+ // Container sits flush bottom-right with a small offset; toasts
174
+ // stack vertically with `gap-2`. No max-width or items-end — the
175
+ // toasts have their own fixed width and naturally align right
176
+ // because the container itself is right-anchored.
177
+ "fixed bottom-4 right-4 z-50 flex flex-col gap-2 " +
178
+ // The gaps between toasts shouldn't intercept clicks on the page
179
+ // beneath. Each toast re-enables pointer-events on itself.
180
+ "pointer-events-none";
181
+ document.body.appendChild(container);
182
+ return container;
183
+ };
184
+
185
+ // =============================================================================
186
+ // Internals — element construction + style swap
187
+ // =============================================================================
188
+
189
+ /** Strip every variant's disc-tint classes from an element. Used in
190
+ * the variant swap path so repeated `update()` calls don't
191
+ * accumulate stacked palettes. */
192
+ const stripAllLeadBg = (el: HTMLElement): void => {
193
+ for (const v of Object.values(VARIANT_STYLES)) {
194
+ for (const cls of splitClasses(v.circleBgClass)) el.classList.remove(cls);
195
+ }
196
+ };
197
+
198
+ /** Reset an element's classes to a fresh class list. Used so swap
199
+ * logic doesn't have to track what was there before. */
200
+ const setClasses = (el: HTMLElement, cls: string): void => {
201
+ el.className = cls;
202
+ };
203
+
204
+ /** Build / rebuild the lead element (the leftmost variant signal): a
205
+ * 36 px soft-tinted disc containing a colour-matched `<i>` icon. On
206
+ * variant swap we replace the disc's children + classes wholesale
207
+ * rather than morph in place.
208
+ *
209
+ * Returns the icon element so the caller can override the icon-class
210
+ * later via `update({ iconClass: ... })`. */
211
+ const renderLead = (
212
+ leadEl: HTMLElement,
213
+ variant: ToastVariant,
214
+ iconClassOverride?: string,
215
+ ): HTMLElement => {
216
+ const style = VARIANT_STYLES[variant];
217
+ leadEl.replaceChildren();
218
+ setClasses(
219
+ leadEl,
220
+ "shrink-0 self-start w-9 h-9 rounded-full flex items-center justify-center",
221
+ );
222
+ for (const cls of splitClasses(style.circleBgClass)) leadEl.classList.add(cls);
223
+ const iconEl = document.createElement("i");
224
+ iconEl.className = `ti ${iconClassOverride ?? style.iconClass} ${style.iconColorClass} text-base`;
225
+ leadEl.appendChild(iconEl);
226
+ return iconEl;
227
+ };
228
+
229
+ // =============================================================================
230
+ // Public API
231
+ // =============================================================================
232
+
233
+ const showToast = (description: string, options?: ToastOptions): ToastHandle => {
234
+ const container = ensureContainer();
235
+ if (!container) {
236
+ const noop = () => {};
237
+ return { dismiss: noop, update: noop };
238
+ }
239
+
240
+ let dismissed = false;
241
+ let dismissTimer: ReturnType<typeof setTimeout> | null = null;
242
+ let currentVariant: ToastVariant = options?.variant ?? "default";
243
+
244
+ // ----- DOM scaffolding -----
245
+
246
+ // Toast card. White / zinc-900 body, neutral text, soft shadow,
247
+ // no border (the lead element is the only color affordance).
248
+ // Fixed width so every toast in the stack lines up. Click-anywhere
249
+ // dismisses — there's no explicit close button.
250
+ const toastEl = document.createElement("div");
251
+ toastEl.className =
252
+ `pointer-events-auto cursor-pointer flex items-stretch gap-3 ${TOAST_WIDTH_CLASS} ` +
253
+ "p-3 rounded-md [box-shadow:var(--theme-shadow-float)] " +
254
+ "bg-white dark:bg-zinc-900 " +
255
+ "transition-all duration-200 ease-out " +
256
+ // Initial off-screen state — flipped on the next frame so the
257
+ // browser renders the entry frame and animates the change.
258
+ "translate-x-2 opacity-0";
259
+
260
+ const leadEl = document.createElement("div");
261
+ let leadIconEl = renderLead(leadEl, currentVariant, options?.iconClass);
262
+
263
+ // Content column — title + description.
264
+ const contentEl = document.createElement("div");
265
+ contentEl.className = "flex-1 min-w-0 self-center flex flex-col gap-0.5";
266
+
267
+ // Title — variant default ("Info" / "Success" / "Error") unless
268
+ // overridden via `options.title`. Subtle weight + tone — toasts
269
+ // are peripheral feedback and loud body text reads as alert.
270
+ const titleEl = document.createElement("div");
271
+ titleEl.className = "text-sm font-medium text-zinc-800 dark:text-zinc-200 leading-tight";
272
+ titleEl.textContent = options?.title ?? VARIANT_STYLES[currentVariant].defaultTitle;
273
+
274
+ // Description (the positional first arg).
275
+ const descEl = document.createElement("div");
276
+ descEl.className = "text-xs text-zinc-500 dark:text-zinc-400 leading-snug";
277
+ descEl.textContent = description;
278
+
279
+ contentEl.appendChild(titleEl);
280
+ contentEl.appendChild(descEl);
281
+
282
+ toastEl.appendChild(leadEl);
283
+ toastEl.appendChild(contentEl);
284
+
285
+ // ----- timer + dismiss -----
286
+
287
+ const clearDismissTimer = () => {
288
+ if (dismissTimer !== null) {
289
+ clearTimeout(dismissTimer);
290
+ dismissTimer = null;
291
+ }
292
+ };
293
+
294
+ const armDismissTimer = (duration: number) => {
295
+ clearDismissTimer();
296
+ if (duration > 0) {
297
+ dismissTimer = setTimeout(() => dismiss(), duration);
298
+ }
299
+ // duration === 0 → sticky; rely on manual dismiss / dismissAll.
300
+ };
301
+
302
+ const dismiss = () => {
303
+ if (dismissed) return;
304
+ dismissed = true;
305
+ clearDismissTimer();
306
+ liveToasts.delete(handle);
307
+ toastEl.classList.remove("translate-x-0", "opacity-100");
308
+ toastEl.classList.add("translate-x-2", "opacity-0");
309
+ setTimeout(() => toastEl.remove(), ANIMATION_MS);
310
+ };
311
+
312
+ const update = (nextDescription: string, nextOptions?: ToastOptions) => {
313
+ if (dismissed) return;
314
+
315
+ // Description is positional and always replaces.
316
+ descEl.textContent = nextDescription;
317
+
318
+ // Variant swap: re-render the lead element wholesale (bar↔circle
319
+ // is a DOM-shape change). Strip stale bg classes first so we
320
+ // don't blend the previous variant's tint into the new one.
321
+ const variantChanged = nextOptions?.variant !== undefined && nextOptions.variant !== currentVariant;
322
+ if (variantChanged) {
323
+ currentVariant = nextOptions.variant!;
324
+ stripAllLeadBg(leadEl);
325
+ leadIconEl = renderLead(leadEl, currentVariant, nextOptions.iconClass);
326
+ } else if (nextOptions?.iconClass !== undefined && leadIconEl) {
327
+ // Same variant, new iconClass — just swap the modifier on the
328
+ // existing icon node. Only meaningful for circle variants.
329
+ for (const cls of Array.from(leadIconEl.classList)) {
330
+ if (cls.startsWith("ti-")) leadIconEl.classList.remove(cls);
331
+ }
332
+ leadIconEl.classList.add(nextOptions.iconClass);
333
+ }
334
+
335
+ // Title:
336
+ // - explicit `options.title` always wins (incl. `""` for empty)
337
+ // - else if variant changed, follow the new variant's default
338
+ // so a `update("...", { variant: "success" })` flips both bar
339
+ // and title to "Success" without forcing the caller to spell
340
+ // out the title string
341
+ // - else leave the title as-is
342
+ if (nextOptions && Object.prototype.hasOwnProperty.call(nextOptions, "title")) {
343
+ titleEl.textContent = nextOptions.title ?? "";
344
+ } else if (variantChanged) {
345
+ titleEl.textContent = VARIANT_STYLES[currentVariant].defaultTitle;
346
+ }
347
+
348
+ armDismissTimer(nextOptions?.duration ?? DEFAULT_DURATION_MS);
349
+ };
350
+
351
+ toastEl.addEventListener("click", dismiss);
352
+
353
+ const handle: ToastHandle = { dismiss, update };
354
+ liveToasts.add(handle);
355
+
356
+ // ----- mount + animate in -----
357
+
358
+ // Cap the visible stack BEFORE we add the new toast so the
359
+ // overflow-removal doesn't visually flicker the new arrival.
360
+ while (container.children.length >= MAX_VISIBLE_TOASTS) {
361
+ container.firstElementChild?.remove();
362
+ }
363
+ container.appendChild(toastEl);
364
+
365
+ requestAnimationFrame(() => {
366
+ toastEl.classList.remove("translate-x-2", "opacity-0");
367
+ toastEl.classList.add("translate-x-0", "opacity-100");
368
+ });
369
+
370
+ armDismissTimer(options?.duration ?? DEFAULT_DURATION_MS);
371
+
372
+ return handle;
373
+ };
374
+
375
+ const toastFn = ((description: string, options?: ToastOptions) => showToast(description, options)) as ToastFn;
376
+
377
+ toastFn.success = (description, options) => showToast(description, { ...options, variant: "success" });
378
+ toastFn.error = (description, options) => showToast(description, { ...options, variant: "error" });
379
+ toastFn.dismissAll = () => {
380
+ // Snapshot — `dismiss()` mutates `liveToasts`.
381
+ for (const handle of Array.from(liveToasts)) handle.dismiss();
382
+ };
383
+
384
+ export const toast: ToastFn = toastFn;
@@ -26,12 +26,18 @@ type WidgetProps = {
26
26
  };
27
27
 
28
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" : ""
29
+ // Header reads as a tinted band (colour, not a divider line) so it separates
30
+ // from the white body without a hairline. The link variant darkens on hover.
31
+ const headerClass = `flex items-center gap-2 px-4 py-2.5 bg-zinc-50 dark:bg-zinc-800/40 ${
32
+ props.href ? "hover:bg-zinc-100 dark:hover:bg-zinc-800/70 transition-colors" : ""
31
33
  }`;
32
34
  const headerInner = (
33
35
  <>
34
- {props.icon ? <i class={`${props.icon} text-dimmed text-sm shrink-0`} /> : null}
36
+ {props.icon ? (
37
+ <span class="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-blue-500/10 text-blue-600 dark:bg-blue-400/15 dark:text-blue-400">
38
+ <i class={`${props.icon} text-sm`} />
39
+ </span>
40
+ ) : null}
35
41
  <span class="text-xs font-semibold uppercase tracking-wider text-secondary truncate">
36
42
  {props.title}
37
43
  </span>
@@ -52,7 +58,9 @@ const Widget = (props: WidgetProps): JSX.Element => {
52
58
  ) : (
53
59
  <div class={headerClass}>{headerInner}</div>
54
60
  )}
55
- <div class="flex-1 flex flex-col divide-y divide-zinc-100 dark:divide-zinc-800 min-h-0">
61
+ {/* Blocks separate by their own padding + tinted blocks (e.g. WidgetStatus)
62
+ carrying their own background — no hairline dividers. */}
63
+ <div class="flex-1 flex flex-col min-h-0">
56
64
  {props.children}
57
65
  </div>
58
66
  </div>
@@ -1,61 +0,0 @@
1
- import { Dropdown } from "../ui";
2
-
3
- type AppLink = {
4
- iconClass: string;
5
- label: string;
6
- href: string;
7
- };
8
-
9
- type LegalLink = {
10
- label: string;
11
- href: string;
12
- icon?: string;
13
- };
14
-
15
- type MoreAppsDropdownProps = {
16
- apps: AppLink[];
17
- /**
18
- * Legal/info links contributed by every running app via `defineApp.legalLinks`.
19
- * Computed server-side via `listLegalLinks()` (or the runtime aggregation in
20
- * Layout.tsx) and passed in as a prop. Empty array = section hidden.
21
- */
22
- legalLinks?: LegalLink[];
23
- };
24
-
25
- /** Dropdown for secondary apps in the rail nav. */
26
- export default function MoreAppsDropdown(props: MoreAppsDropdownProps) {
27
- const legalLinks = props.legalLinks ?? [];
28
- const trigger = (
29
- <span class="rail-item" title="More">
30
- <i class="ti ti-dots-vertical text-base" />
31
- </span>
32
- );
33
-
34
- const appItems = props.apps.map((app) => ({
35
- icon: app.iconClass,
36
- label: app.label,
37
- href: app.href,
38
- }));
39
-
40
- const legalItems = legalLinks.map((link) => ({
41
- icon: link.icon ?? "ti ti-file-text",
42
- label: link.label,
43
- href: link.href,
44
- }));
45
-
46
- const elements = legalItems.length > 0
47
- ? [
48
- ...(appItems.length > 0 ? [{ items: appItems }] : []),
49
- { sectionLabel: "Legal", items: legalItems },
50
- ]
51
- : appItems;
52
-
53
- return (
54
- <Dropdown
55
- trigger={trigger}
56
- elements={elements}
57
- position="bottom-right"
58
- width="w-44"
59
- />
60
- );
61
- }
@@ -1,36 +0,0 @@
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
- }
@@ -1,16 +0,0 @@
1
- type LoginBtnProps = {
2
- redirectTo?: string;
3
- class?: string;
4
- };
5
-
6
- /** Link styled as a button that navigates to the login page. */
7
- export default function LoginBtn(props: LoginBtnProps) {
8
- const href = props.redirectTo ? `/auth/login?redirectTo=${encodeURIComponent(props.redirectTo)}` : "/auth/login";
9
-
10
- return (
11
- <a href={href} class={props.class ?? "btn-primary"}>
12
- <i class="ti ti-login" />
13
- <span>Sign In</span>
14
- </a>
15
- );
16
- }
@@ -1,58 +0,0 @@
1
- import type { BaseUser } from "../../contracts/shared";
2
-
3
- type UserViewProps = {
4
- user: BaseUser;
5
- showRealm?: boolean;
6
- };
7
-
8
- const badgeStyles: Record<`${"ipa" | "local"}:${"user" | "guest"}`, { bg: string; text: string; label: string }> = {
9
- "ipa:user": {
10
- bg: "bg-green-100 dark:bg-green-900/30",
11
- text: "text-green-700 dark:text-green-400",
12
- label: "IPA",
13
- },
14
- "ipa:guest": {
15
- bg: "bg-yellow-100 dark:bg-yellow-900/30",
16
- text: "text-yellow-700 dark:text-yellow-400",
17
- label: "IPA Guest",
18
- },
19
- "local:user": {
20
- bg: "bg-sky-100 dark:bg-sky-900/30",
21
- text: "text-sky-700 dark:text-sky-400",
22
- label: "Local",
23
- },
24
- "local:guest": {
25
- bg: "bg-zinc-100 dark:bg-zinc-800",
26
- text: "text-zinc-600 dark:text-zinc-400",
27
- label: "Guest",
28
- },
29
- };
30
-
31
- export default function UserView(props: UserViewProps) {
32
- const badge = () => badgeStyles[`${props.user.provider}:${props.user.profile}`] ?? badgeStyles["local:guest"];
33
-
34
- return (
35
- <div class="flex items-start gap-3 min-w-0">
36
- <div 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 h-9 w-9 text-xs">
37
- {props.user.uid.slice(0, 2).toUpperCase()}
38
- </div>
39
- <div class="flex flex-col gap-0.5 min-w-0">
40
- <div class="flex items-center gap-2">
41
- <span class="text-sm font-medium text-primary truncate">{props.user.displayName}</span>
42
- {props.showRealm && badge() !== undefined && (
43
- <span class={`tag ${badge()?.bg} ${badge()?.text}`}>{badge()?.label}</span>
44
- )}
45
- </div>
46
- <div class="flex items-center gap-2 text-xs text-dimmed">
47
- <span class="font-mono">{props.user.profile === "guest" ? `${props.user.uid.slice(0, 12)}...` : props.user.uid}</span>
48
- {props.user.mail && (
49
- <>
50
- <span class="text-zinc-300 dark:text-zinc-600">|</span>
51
- <span class="truncate">{props.user.mail}</span>
52
- </>
53
- )}
54
- </div>
55
- </div>
56
- </div>
57
- );
58
- }
@@ -1,4 +0,0 @@
1
- export { default as Avatar } from "./Avatar";
2
- export { default as UserView } from "./UserView";
3
- export { default as GroupView } from "./GroupView";
4
- export { default as LoginBtn } from "./LoginBtn";
@@ -1,32 +0,0 @@
1
- /**
2
- * Browser-side navigation helpers — shared across every app's islands.
3
- *
4
- * Replaces the per-app `lib/navigation.ts` modules that all reimplemented the
5
- * same handful of `window.location` wrappers. Re-exported from the `cloud/ui`
6
- * barrel so consumers `import { navigateTo, refreshCurrentPath } from
7
- * "@valentinkolb/cloud/ui"`.
8
- */
9
-
10
- /**
11
- * Returns the canonical current URL path + query (without hash).
12
- * Used as a deterministic refresh target after mutations — `location.reload()`
13
- * preserves hash and forces a network revalidation we don't always want.
14
- */
15
- export const currentPathWithQuery = (): string => {
16
- const url = new URL(window.location.href);
17
- return `${url.pathname}${url.search}`;
18
- };
19
-
20
- /**
21
- * Navigates to the canonical current URL. Triggers full SSR re-render.
22
- */
23
- export const refreshCurrentPath = (): void => {
24
- window.location.assign(currentPathWithQuery());
25
- };
26
-
27
- /**
28
- * Navigates to a target href via browser navigation (adds history entry).
29
- */
30
- export const navigateTo = (href: string): void => {
31
- window.location.assign(href);
32
- };