@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/input/Select.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
2
|
+
import { mutation, timed } from "@valentinkolb/stdlib/solid";
|
|
2
3
|
import { InputWrapper, createInputA11y } from "./util";
|
|
3
4
|
|
|
4
5
|
type SelectOption =
|
|
@@ -10,6 +11,15 @@ type SelectOption =
|
|
|
10
11
|
icon?: string;
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Async loader for searchable selects. Receives the current query
|
|
16
|
+
* (empty on initial open) and an AbortSignal that's tripped when the
|
|
17
|
+
* caller types again before the previous request resolves, or when
|
|
18
|
+
* the dropdown closes mid-flight. Throw to surface an error in the
|
|
19
|
+
* dropdown body — users get a Retry button.
|
|
20
|
+
*/
|
|
21
|
+
type FetchDataFn = (query: string, signal: AbortSignal) => Promise<SelectOption[]>;
|
|
22
|
+
|
|
13
23
|
type SelectInputProps = {
|
|
14
24
|
label?: string;
|
|
15
25
|
description?: string;
|
|
@@ -19,7 +29,22 @@ type SelectInputProps = {
|
|
|
19
29
|
value?: () => string | undefined;
|
|
20
30
|
onChange?: (value: string) => void;
|
|
21
31
|
error?: () => string | undefined;
|
|
22
|
-
|
|
32
|
+
/** Static option list. Required when `fetchData` is not set; ignored
|
|
33
|
+
* (still allowed for type ergonomics) when it is. */
|
|
34
|
+
options?: SelectOption[];
|
|
35
|
+
/** Async loader. When set, the dropdown becomes searchable: a search
|
|
36
|
+
* field renders at the top, the body shows loading / error / results,
|
|
37
|
+
* and `options` is ignored. The fetcher is wrapped in a stdlib
|
|
38
|
+
* mutation internally so loading + error + abort come for free. */
|
|
39
|
+
fetchData?: FetchDataFn;
|
|
40
|
+
/** Cached label for the currently-selected id when the caller already
|
|
41
|
+
* knows it (e.g. from a server-side join). Lets the trigger render
|
|
42
|
+
* the right text immediately on first paint, without waiting for
|
|
43
|
+
* fetchData to find the id. Falls back to a small internal cache
|
|
44
|
+
* populated when the user picks options, then to the id itself. */
|
|
45
|
+
selectedLabel?: () => string | undefined;
|
|
46
|
+
/** Debounce window for fetchData calls, in ms. Default 200. */
|
|
47
|
+
fetchDebounceMs?: number;
|
|
23
48
|
required?: boolean;
|
|
24
49
|
clearable?: boolean;
|
|
25
50
|
disabled?: boolean;
|
|
@@ -31,11 +56,58 @@ const SelectInput = (props: SelectInputProps) => {
|
|
|
31
56
|
const activeIcon = () => props.activeIcon ?? "ti ti-chevron-up";
|
|
32
57
|
const disabled = () => props.disabled ?? false;
|
|
33
58
|
const clearable = () => props.clearable ?? false;
|
|
59
|
+
const isSearchable = () => Boolean(props.fetchData);
|
|
60
|
+
|
|
61
|
+
// Normalize a raw option (string | object) into the always-object shape
|
|
62
|
+
// the renderer expects. Used for both static options and fetchData
|
|
63
|
+
// results so the rendering loop doesn't need to branch.
|
|
64
|
+
const normalize = (option: SelectOption) =>
|
|
65
|
+
typeof option === "object" ? { ...option, label: option.label || option.id } : { id: option, label: option };
|
|
66
|
+
|
|
67
|
+
// ── Search-mode state ────────────────────────────────────────────────
|
|
68
|
+
// Only meaningful when fetchData is set. Defaults are inert otherwise
|
|
69
|
+
// so static-mode callers see exactly the previous behaviour.
|
|
70
|
+
const [searchQuery, setSearchQuery] = createSignal("");
|
|
71
|
+
|
|
72
|
+
// Internal label cache — populated whenever the user picks an option.
|
|
73
|
+
// Lets the trigger keep rendering the right label after dropdown
|
|
74
|
+
// close + reopen, even if the new fetchData call no longer surfaces
|
|
75
|
+
// that record (e.g. the user typed a different filter).
|
|
76
|
+
const [labelCache, setLabelCache] = createSignal<Record<string, string>>({});
|
|
77
|
+
|
|
78
|
+
// Wrap the caller's fetchData in a stdlib mutation so we get:
|
|
79
|
+
// - reactive loading / error / data signals
|
|
80
|
+
// - one in-flight request at a time (we explicitly abort() before
|
|
81
|
+
// each new mutate() call in the debounced trigger below)
|
|
82
|
+
// - AbortSignal threaded through to fetch()
|
|
83
|
+
// When fetchData is undefined we still create the mutation but never
|
|
84
|
+
// call it — keeps the hook order stable across re-renders.
|
|
85
|
+
const fetchMut = mutation.create<{ id: string; label: string; description?: string; icon?: string }[], string>({
|
|
86
|
+
mutation: async (query, { abortSignal }) => {
|
|
87
|
+
if (!props.fetchData) return [];
|
|
88
|
+
const raw = await props.fetchData(query, abortSignal);
|
|
89
|
+
return raw.map(normalize);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const triggerFetch = (query: string) => {
|
|
94
|
+
if (!props.fetchData) return;
|
|
95
|
+
fetchMut.abort(); // cancel any in-flight previous request
|
|
96
|
+
void fetchMut.mutate(query);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// 200ms is a sweet spot: fast enough that typing feels live, slow
|
|
100
|
+
// enough to avoid hammering the server on every keystroke.
|
|
101
|
+
const debounce = timed.debounce((q: string) => triggerFetch(q), props.fetchDebounceMs ?? 200);
|
|
34
102
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
103
|
+
// ── Options resolution ───────────────────────────────────────────────
|
|
104
|
+
// In static mode → the `options` prop. In fetch mode → the mutation's
|
|
105
|
+
// last successful payload. Either way the renderer sees the same
|
|
106
|
+
// normalized {id, label, ...} shape.
|
|
107
|
+
const options = createMemo(() => {
|
|
108
|
+
if (isSearchable()) return fetchMut.data() ?? [];
|
|
109
|
+
return (props.options ?? []).map(normalize);
|
|
110
|
+
});
|
|
39
111
|
|
|
40
112
|
const [isOpen, setIsOpen] = createSignal(false);
|
|
41
113
|
const [focusedIndex, setFocusedIndex] = createSignal(-1);
|
|
@@ -44,9 +116,24 @@ const SelectInput = (props: SelectInputProps) => {
|
|
|
44
116
|
|
|
45
117
|
let triggerRef: HTMLDivElement | undefined;
|
|
46
118
|
let dialogRef: HTMLDialogElement | undefined;
|
|
119
|
+
let searchInputRef: HTMLInputElement | undefined;
|
|
47
120
|
let optionRefs: HTMLDivElement[] = [];
|
|
48
121
|
|
|
49
|
-
|
|
122
|
+
// Resolve the currently-selected option's display row. Tries (in
|
|
123
|
+
// order): the live options list, the internal cache, the caller's
|
|
124
|
+
// selectedLabel getter, finally the id itself as a last-resort
|
|
125
|
+
// fallback so the trigger never goes blank when a value is set.
|
|
126
|
+
const selectedOption = createMemo(() => {
|
|
127
|
+
const id = props.value?.();
|
|
128
|
+
if (!id) return undefined;
|
|
129
|
+
const fromList = options().find((o) => o.id === id);
|
|
130
|
+
if (fromList) return fromList;
|
|
131
|
+
const cached = labelCache()[id];
|
|
132
|
+
if (cached) return { id, label: cached };
|
|
133
|
+
const fromProp = props.selectedLabel?.();
|
|
134
|
+
if (fromProp) return { id, label: fromProp };
|
|
135
|
+
return { id, label: id };
|
|
136
|
+
});
|
|
50
137
|
|
|
51
138
|
const syncTheme = () => {
|
|
52
139
|
if (typeof document === "undefined") return;
|
|
@@ -80,6 +167,15 @@ const SelectInput = (props: SelectInputProps) => {
|
|
|
80
167
|
if (!open) {
|
|
81
168
|
dialogRef?.close();
|
|
82
169
|
setFocusedIndex(-1);
|
|
170
|
+
// Bail any in-flight request so a late response doesn't repaint
|
|
171
|
+
// a closed dropdown with stale results. Reset query so the next
|
|
172
|
+
// open starts fresh (matches user expectation of "open === fresh
|
|
173
|
+
// search").
|
|
174
|
+
if (isSearchable()) {
|
|
175
|
+
debounce.cancel();
|
|
176
|
+
fetchMut.abort();
|
|
177
|
+
setSearchQuery("");
|
|
178
|
+
}
|
|
83
179
|
return;
|
|
84
180
|
}
|
|
85
181
|
|
|
@@ -90,7 +186,10 @@ const SelectInput = (props: SelectInputProps) => {
|
|
|
90
186
|
const rect = triggerRef.getBoundingClientRect();
|
|
91
187
|
const spaceBelow = window.innerHeight - rect.bottom;
|
|
92
188
|
const spaceAbove = rect.top;
|
|
93
|
-
|
|
189
|
+
// Searchable dropdowns are taller because of the sticky search
|
|
190
|
+
// header on top — bump the height budget so the auto-flip logic
|
|
191
|
+
// doesn't choose the wrong side.
|
|
192
|
+
const dropdownMaxHeight = isSearchable() ? 320 : 260;
|
|
94
193
|
|
|
95
194
|
dialogRef.style.left = `${rect.left}px`;
|
|
96
195
|
dialogRef.style.width = `${rect.width}px`;
|
|
@@ -106,10 +205,23 @@ const SelectInput = (props: SelectInputProps) => {
|
|
|
106
205
|
}
|
|
107
206
|
|
|
108
207
|
dialogRef.showModal();
|
|
208
|
+
|
|
209
|
+
// In search mode: kick off an empty-query fetch so the user sees
|
|
210
|
+
// recent / default results immediately, and move keyboard focus
|
|
211
|
+
// into the search input so they can start typing right away.
|
|
212
|
+
if (isSearchable()) {
|
|
213
|
+
triggerFetch("");
|
|
214
|
+
// Defer the focus until the dialog is actually rendered.
|
|
215
|
+
queueMicrotask(() => searchInputRef?.focus());
|
|
216
|
+
}
|
|
109
217
|
}
|
|
110
218
|
};
|
|
111
219
|
|
|
112
220
|
const selectOption = (option: { id: string; label: string }) => {
|
|
221
|
+
// Cache the picked label so the trigger keeps showing it after
|
|
222
|
+
// close + reopen, even if the next fetchData call doesn't surface
|
|
223
|
+
// this id (e.g. user typed a different filter mid-flow).
|
|
224
|
+
setLabelCache({ ...labelCache(), [option.id]: option.label });
|
|
113
225
|
props.onChange?.(option.id);
|
|
114
226
|
toggleDropdown(false);
|
|
115
227
|
triggerRef?.focus();
|
|
@@ -205,8 +317,15 @@ const SelectInput = (props: SelectInputProps) => {
|
|
|
205
317
|
aria-required={props.required}
|
|
206
318
|
aria-disabled={disabled()}
|
|
207
319
|
>
|
|
208
|
-
|
|
209
|
-
|
|
320
|
+
{/* `block truncate` keeps long option labels on one line —
|
|
321
|
+
in narrow toolbar selects (group-by row, sort row,
|
|
322
|
+
filter row) the trigger stays single-row instead of
|
|
323
|
+
wrapping to two. */}
|
|
324
|
+
<Show
|
|
325
|
+
when={selectedOption()}
|
|
326
|
+
fallback={<span class="block truncate text-zinc-400 dark:text-zinc-500">{placeholder()}</span>}
|
|
327
|
+
>
|
|
328
|
+
<span class="block truncate text-zinc-700 dark:text-zinc-300">{selectedOption()!.label}</span>
|
|
210
329
|
</Show>
|
|
211
330
|
</div>
|
|
212
331
|
|
|
@@ -231,8 +350,51 @@ const SelectInput = (props: SelectInputProps) => {
|
|
|
231
350
|
onClick={handleDialogClick}
|
|
232
351
|
aria-label="Options"
|
|
233
352
|
>
|
|
353
|
+
{/* Search input — only in fetchData mode. Borderless, no
|
|
354
|
+
icon, no divider underneath: the dropdown's own border
|
|
355
|
+
is the visual frame, an extra HR would just add noise.
|
|
356
|
+
onInput updates the visible query immediately and
|
|
357
|
+
schedules the debounced fetch. */}
|
|
358
|
+
<Show when={isSearchable()}>
|
|
359
|
+
<input
|
|
360
|
+
ref={searchInputRef}
|
|
361
|
+
type="text"
|
|
362
|
+
placeholder="Search..."
|
|
363
|
+
value={searchQuery()}
|
|
364
|
+
onInput={(e) => {
|
|
365
|
+
const v = e.currentTarget.value;
|
|
366
|
+
setSearchQuery(v);
|
|
367
|
+
debounce.debouncedFn(v);
|
|
368
|
+
}}
|
|
369
|
+
class="w-full bg-transparent px-3 py-1.5 text-sm text-zinc-700 placeholder:text-zinc-400 outline-none border-0 focus:ring-0 dark:text-zinc-300 dark:placeholder:text-zinc-500"
|
|
370
|
+
aria-label="Search options"
|
|
371
|
+
/>
|
|
372
|
+
</Show>
|
|
234
373
|
<div class="flex max-h-60 flex-col gap-1 overflow-y-auto" role="listbox" aria-label={props.label || "Options"}>
|
|
235
|
-
|
|
374
|
+
{/* Loading row — single dim line. The previous results
|
|
375
|
+
would be more jarring than a small spinner here. */}
|
|
376
|
+
<Show when={isSearchable() && fetchMut.loading()}>
|
|
377
|
+
<div class="flex items-center gap-1.5 px-3 py-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
|
378
|
+
<i class="ti ti-loader-2 animate-spin" /> Loading...
|
|
379
|
+
</div>
|
|
380
|
+
</Show>
|
|
381
|
+
{/* Error row — caller's fetchData threw. Surface message +
|
|
382
|
+
Retry. retry() reuses the last query without bouncing
|
|
383
|
+
through the debounce. */}
|
|
384
|
+
<Show when={isSearchable() && !fetchMut.loading() && fetchMut.error()}>
|
|
385
|
+
<div class="flex items-center gap-2 px-3 py-1.5 text-xs text-red-500">
|
|
386
|
+
<i class="ti ti-alert-triangle shrink-0" />
|
|
387
|
+
<span class="flex-1 truncate">{fetchMut.error()?.message ?? "Failed to load"}</span>
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
class="text-zinc-500 underline hover:text-zinc-700 dark:hover:text-zinc-300"
|
|
391
|
+
onClick={() => fetchMut.retry()}
|
|
392
|
+
>
|
|
393
|
+
Retry
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
</Show>
|
|
397
|
+
<For each={isSearchable() && (fetchMut.loading() || fetchMut.error()) ? [] : options()} fallback={<div class="px-3 py-1.5 text-xs text-zinc-400 dark:text-zinc-500">{isSearchable() ? "No results" : "No options available"}</div>}>
|
|
236
398
|
{(option, index) => {
|
|
237
399
|
const isSelected = () => option.id === props.value?.();
|
|
238
400
|
const isFocused = () => index() === focusedIndex();
|
package/src/ui/input/Slider.tsx
CHANGED
|
@@ -80,7 +80,7 @@ const Slider = ({
|
|
|
80
80
|
disabled={disabled}
|
|
81
81
|
aria-describedby={descId}
|
|
82
82
|
class="w-full h-1.5 appearance-none cursor-pointer
|
|
83
|
-
rounded-full
|
|
83
|
+
rounded-full [box-shadow:var(--theme-recess)]
|
|
84
84
|
[&::-webkit-slider-thumb]:appearance-none
|
|
85
85
|
[&::-webkit-slider-thumb]:w-3.5
|
|
86
86
|
[&::-webkit-slider-thumb]:h-3.5
|
|
@@ -103,9 +103,8 @@ const Slider = ({
|
|
|
103
103
|
]:rounded-none
|
|
104
104
|
]:bg-(--slider-fill)
|
|
105
105
|
focus-visible:outline-none
|
|
106
|
-
focus-visible:[&::-webkit-slider-thumb]:ring
|
|
107
|
-
focus-visible:[&::-
|
|
108
|
-
focus-visible:[&::-webkit-slider-thumb]:ring-offset-2
|
|
106
|
+
focus-visible:[&::-webkit-slider-thumb]:[box-shadow:var(--theme-focus-ring)]
|
|
107
|
+
focus-visible:[&::-moz-range-thumb]:[box-shadow:var(--theme-focus-ring)]
|
|
109
108
|
disabled:cursor-not-allowed
|
|
110
109
|
disabled:[&::-webkit-slider-thumb]:bg-zinc-400
|
|
111
110
|
disabled:[&::-moz-range-thumb]:bg-zinc-400"
|
package/src/ui/input/Switch.tsx
CHANGED
|
@@ -25,11 +25,12 @@ const Switch = ({ label, value, onChange, disabled = false }: SwitchInputProps)
|
|
|
25
25
|
class={`
|
|
26
26
|
relative transition-colors
|
|
27
27
|
w-9 h-5 rounded-full
|
|
28
|
-
|
|
28
|
+
[box-shadow:var(--theme-recess)]
|
|
29
|
+
|
|
29
30
|
bg-zinc-200 dark:bg-zinc-600/40
|
|
30
31
|
peer-checked:bg-blue-500
|
|
31
32
|
|
|
32
|
-
peer-focus-visible:
|
|
33
|
+
peer-focus-visible:[box-shadow:var(--theme-focus-ring)]
|
|
33
34
|
|
|
34
35
|
peer-disabled:opacity-50
|
|
35
36
|
`}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createMemo, For } from "solid-js";
|
|
2
|
+
import type { Completion, Suggestion } from "../completion";
|
|
3
|
+
import type { PanesValue } from "../misc/Panes";
|
|
4
|
+
import AutocompleteEditor from "./AutocompleteEditor";
|
|
5
|
+
import TextInput from "./TextInput";
|
|
6
|
+
|
|
7
|
+
export type TemplateVariableKind = "string" | "email" | "url" | "number" | "boolean" | "array" | "object";
|
|
8
|
+
|
|
9
|
+
export type TemplateVariable = {
|
|
10
|
+
name: string;
|
|
11
|
+
kind?: TemplateVariableKind;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type TemplateEditorProps = {
|
|
15
|
+
value: () => string;
|
|
16
|
+
onInput: (value: string) => void;
|
|
17
|
+
variables: readonly TemplateVariable[];
|
|
18
|
+
lines?: number;
|
|
19
|
+
fill?: boolean;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TemplatePreviewProps = {
|
|
24
|
+
html: () => string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type TemplateSampleDataProps = {
|
|
28
|
+
variables: readonly TemplateVariable[];
|
|
29
|
+
values: () => Record<string, string>;
|
|
30
|
+
onChange: (name: string, value: string) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const createTemplateEditorPanesValue = (): PanesValue => ({
|
|
34
|
+
root: {
|
|
35
|
+
type: "split",
|
|
36
|
+
id: "template-editor-root",
|
|
37
|
+
direction: "horizontal",
|
|
38
|
+
sizes: [50, 50],
|
|
39
|
+
children: [
|
|
40
|
+
{
|
|
41
|
+
type: "leaf",
|
|
42
|
+
id: "template-editor-source",
|
|
43
|
+
presentation: "tabs",
|
|
44
|
+
elementIds: ["html"],
|
|
45
|
+
activeElementId: "html",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: "leaf",
|
|
49
|
+
id: "template-editor-output",
|
|
50
|
+
presentation: "tabs",
|
|
51
|
+
elementIds: ["preview", "sample-data"],
|
|
52
|
+
activeElementId: "preview",
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const HTML_TAGS = [
|
|
59
|
+
{ name: "p", snippet: "<p></p>", hint: "paragraph" },
|
|
60
|
+
{ name: "a", snippet: '<a href="{{LOGIN_URL}}">Link</a>', hint: "link" },
|
|
61
|
+
{ name: "strong", snippet: "<strong></strong>", hint: "bold" },
|
|
62
|
+
{ name: "em", snippet: "<em></em>", hint: "emphasis" },
|
|
63
|
+
{ name: "br", snippet: "<br>", hint: "line break" },
|
|
64
|
+
{ name: "ul", snippet: "<ul>\n <li></li>\n</ul>", hint: "list" },
|
|
65
|
+
{ name: "table", snippet: "<table>\n <tr><td></td></tr>\n</table>", hint: "table" },
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
const escapeHtml = (value: string): string =>
|
|
69
|
+
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
70
|
+
|
|
71
|
+
const buildSuggestion = (text: string, hint: string, label?: string): Suggestion => ({
|
|
72
|
+
text,
|
|
73
|
+
hint,
|
|
74
|
+
label,
|
|
75
|
+
appendSpace: false,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const makeCompletions = (variables: readonly TemplateVariable[]): Completion[] => [
|
|
79
|
+
{
|
|
80
|
+
trigger: "{",
|
|
81
|
+
dropdown: true,
|
|
82
|
+
allowAfterWord: true,
|
|
83
|
+
suggest: (query, ctx) => {
|
|
84
|
+
const normalized = query.toLowerCase();
|
|
85
|
+
const hasLeadingBrace = ctx.tokenStart > 0 && ctx.fullText[ctx.tokenStart - 1] === "{";
|
|
86
|
+
const open = hasLeadingBrace ? "{" : "{{";
|
|
87
|
+
const variableSuggestions = variables
|
|
88
|
+
.filter((variable) => variable.name.toLowerCase().startsWith(normalized))
|
|
89
|
+
.map((variable) => buildSuggestion(`${open}${variable.name}}}`, variable.kind ?? "string", variable.name));
|
|
90
|
+
|
|
91
|
+
const sectionSuggestions = variables
|
|
92
|
+
.filter((variable) => variable.kind === "array" || variable.kind === "object" || variable.kind === "boolean")
|
|
93
|
+
.filter((variable) => variable.name.toLowerCase().startsWith(normalized))
|
|
94
|
+
.map((variable) => buildSuggestion(`${open}#${variable.name}}}\n \n{{/${variable.name}}}`, "section", `#${variable.name}`));
|
|
95
|
+
|
|
96
|
+
return [...variableSuggestions, ...sectionSuggestions];
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
trigger: "#",
|
|
101
|
+
dropdown: true,
|
|
102
|
+
allowAfterWord: true,
|
|
103
|
+
suggest: (query) => {
|
|
104
|
+
const normalized = query.toLowerCase();
|
|
105
|
+
return variables
|
|
106
|
+
.filter((variable) => variable.kind === "array" || variable.kind === "object" || variable.kind === "boolean")
|
|
107
|
+
.filter((variable) => variable.name.toLowerCase().startsWith(normalized))
|
|
108
|
+
.map((variable) => buildSuggestion(`#${variable.name}}}\n \n{{/${variable.name}}}`, "section", `#${variable.name}`));
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
trigger: "<",
|
|
113
|
+
dropdown: true,
|
|
114
|
+
allowAfterWord: true,
|
|
115
|
+
suggest: (query) => {
|
|
116
|
+
const normalized = query.toLowerCase();
|
|
117
|
+
return HTML_TAGS.filter((tag) => tag.name.startsWith(normalized)).map((tag) => buildSuggestion(tag.snippet, tag.hint, tag.name));
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const highlightMustacheToken = (token: string): string => {
|
|
123
|
+
const inner = token
|
|
124
|
+
.replace(/^\{\{\{?/, "")
|
|
125
|
+
.replace(/\}\}\}?$/, "")
|
|
126
|
+
.trim();
|
|
127
|
+
const prefix = inner[0];
|
|
128
|
+
const tone =
|
|
129
|
+
prefix === "#"
|
|
130
|
+
? "text-emerald-600 dark:text-emerald-400"
|
|
131
|
+
: prefix === "/"
|
|
132
|
+
? "text-amber-600 dark:text-amber-400"
|
|
133
|
+
: prefix === "^"
|
|
134
|
+
? "text-rose-600 dark:text-rose-400"
|
|
135
|
+
: token.startsWith("{{{")
|
|
136
|
+
? "text-red-600 dark:text-red-400"
|
|
137
|
+
: "text-violet-600 dark:text-violet-400";
|
|
138
|
+
return `<span class="${tone}">${escapeHtml(token)}</span>`;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const highlightTag = (tag: string): string => {
|
|
142
|
+
const match = tag.match(/^(<\/?)([a-zA-Z][\w:-]*)([\s\S]*?)(>)$/);
|
|
143
|
+
if (!match) return `<span class="text-zinc-500 dark:text-zinc-400">${tag}</span>`;
|
|
144
|
+
const open = match[1] ?? "";
|
|
145
|
+
const name = match[2] ?? "";
|
|
146
|
+
const attrs = match[3] ?? "";
|
|
147
|
+
const close = match[4] ?? "";
|
|
148
|
+
const attrHtml = attrs.replace(
|
|
149
|
+
/([\w:-]+)(=)("[\s\S]*?"|'[\s\S]*?'|[^\s&]+)/g,
|
|
150
|
+
'<span class="text-sky-600 dark:text-sky-400">$1</span><span class="text-zinc-400">$2</span><span class="text-orange-600 dark:text-orange-400">$3</span>',
|
|
151
|
+
);
|
|
152
|
+
return `<span class="text-zinc-400">${open}</span><span class="text-blue-600 dark:text-blue-400">${name}</span>${attrHtml}<span class="text-zinc-400">${close}</span>`;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const highlightTemplate = (text: string): string => {
|
|
156
|
+
const stashed: string[] = [];
|
|
157
|
+
const escaped = escapeHtml(text).replace(/{{{?[\s\S]*?}}}?/g, (token) => {
|
|
158
|
+
const marker = `\uE000${stashed.length}\uE001`;
|
|
159
|
+
stashed.push(highlightMustacheToken(token));
|
|
160
|
+
return marker;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const withHtml = escaped.replace(/<!--[\s\S]*?-->|<\/?[a-zA-Z][\s\S]*?>/g, (tag) => {
|
|
164
|
+
if (tag.startsWith("<!--")) return `<span class="text-zinc-400 dark:text-zinc-500">${tag}</span>`;
|
|
165
|
+
return highlightTag(tag);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return withHtml.replace(/\uE000(\d+)\uE001/g, (_, index) => stashed[Number(index)] ?? "");
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export default function TemplateEditor(props: TemplateEditorProps) {
|
|
172
|
+
const completions = createMemo(() => makeCompletions(props.variables));
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<AutocompleteEditor
|
|
176
|
+
value={props.value}
|
|
177
|
+
onInput={props.onInput}
|
|
178
|
+
lines={props.lines ?? 22}
|
|
179
|
+
fill={props.fill}
|
|
180
|
+
spellcheck={false}
|
|
181
|
+
placeholder={props.placeholder ?? "Write HTML with {{MUSTACHE_VALUES}}..."}
|
|
182
|
+
highlight={highlightTemplate}
|
|
183
|
+
completions={completions()}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function TemplatePreview(props: TemplatePreviewProps) {
|
|
189
|
+
return (
|
|
190
|
+
<section class="paper flex h-full min-h-0 flex-col overflow-hidden">
|
|
191
|
+
<iframe class="min-h-0 flex-1 bg-white" sandbox="" srcdoc={props.html()} title="Template preview" />
|
|
192
|
+
</section>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function TemplateSampleData(props: TemplateSampleDataProps) {
|
|
197
|
+
return (
|
|
198
|
+
<section class="paper h-full min-h-0 overflow-auto p-3">
|
|
199
|
+
<div class="grid gap-3">
|
|
200
|
+
<For each={props.variables}>
|
|
201
|
+
{(variable) => (
|
|
202
|
+
<TextInput
|
|
203
|
+
label={`{{${variable.name}}}`}
|
|
204
|
+
value={() => props.values()[variable.name] ?? ""}
|
|
205
|
+
onInput={(value) => props.onChange(variable.name, value)}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
</For>
|
|
209
|
+
</div>
|
|
210
|
+
</section>
|
|
211
|
+
);
|
|
212
|
+
}
|