@valentinkolb/cloud 0.4.0 → 0.5.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.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
package/src/ui/toast.ts
ADDED
|
@@ -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
|
-
|
|
30
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
}
|
package/src/ui/ipa/GroupView.tsx
DELETED
|
@@ -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
|
-
}
|
package/src/ui/ipa/LoginBtn.tsx
DELETED
|
@@ -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
|
-
}
|
package/src/ui/ipa/UserView.tsx
DELETED
|
@@ -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
|
-
}
|
package/src/ui/ipa/index.ts
DELETED
package/src/ui/navigation.ts
DELETED
|
@@ -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
|
-
};
|