@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dates, type DateContext } from "@valentinkolb/stdlib";
|
|
1
2
|
import { InputWrapper, createInputA11y } from "./util";
|
|
2
3
|
|
|
3
4
|
type DateTimeInputProps = {
|
|
@@ -11,15 +12,25 @@ type DateTimeInputProps = {
|
|
|
11
12
|
disabled?: boolean;
|
|
12
13
|
/** Use date-only input instead of datetime-local */
|
|
13
14
|
dateOnly?: boolean;
|
|
15
|
+
/** Optional stdlib date context. When set, datetime values are edited in this timezone. */
|
|
16
|
+
dateConfig?: DateContext;
|
|
17
|
+
/** Convenience override for dateConfig.timeZone. */
|
|
18
|
+
timeZone?: string;
|
|
14
19
|
};
|
|
15
20
|
|
|
21
|
+
const hasInstantOffset = (value: string) => /[T\s].*([zZ]|[+-]\d{2}:?\d{2})$/.test(value);
|
|
22
|
+
|
|
16
23
|
/**
|
|
17
|
-
* Date/DateTime input component using native browser inputs
|
|
24
|
+
* Date/DateTime input component using native browser inputs.
|
|
25
|
+
*
|
|
26
|
+
* @deprecated Use `DatePicker` or `DateTimePicker` from `@valentinkolb/cloud/ui`
|
|
27
|
+
* for new UI. This component stays exported for compatibility with older apps.
|
|
28
|
+
*
|
|
18
29
|
* @param label - Optional label text
|
|
19
30
|
* @param description - Optional description text
|
|
20
31
|
* @param placeholder - Placeholder text (not shown in date inputs)
|
|
21
32
|
* @param value - Reactive value getter (ISO string or datetime-local format)
|
|
22
|
-
* @param onChange - Called
|
|
33
|
+
* @param onChange - Called with a date key, local datetime string, or UTC instant when dateConfig/timeZone is set
|
|
23
34
|
* @param error - Reactive error message getter
|
|
24
35
|
* @param required - Show required asterisk after label
|
|
25
36
|
* @param disabled - Disable the input
|
|
@@ -30,13 +41,22 @@ const DateTimeInput = (props: DateTimeInputProps) => {
|
|
|
30
41
|
const dateOnly = () => props.dateOnly ?? false;
|
|
31
42
|
const icon = () => (dateOnly() ? "ti ti-calendar" : "ti ti-calendar-time");
|
|
32
43
|
const a11y = createInputA11y({ description: props.description, error: props.error });
|
|
44
|
+
const dateContext = (): DateContext => ({
|
|
45
|
+
...props.dateConfig,
|
|
46
|
+
timeZone: props.timeZone ?? props.dateConfig?.timeZone,
|
|
47
|
+
});
|
|
48
|
+
const timezone = () => dateContext().timeZone;
|
|
33
49
|
|
|
34
50
|
// Convert ISO string to input format if needed
|
|
35
51
|
const inputValue = () => {
|
|
36
52
|
const v = props.value?.();
|
|
37
53
|
if (!v) return "";
|
|
38
54
|
// If it's already in the right format, return as-is
|
|
39
|
-
if (!
|
|
55
|
+
if (!hasInstantOffset(v)) return v;
|
|
56
|
+
if (timezone()) {
|
|
57
|
+
if (dateOnly()) return dates.formatDateKey(v, dateContext());
|
|
58
|
+
return dates.instantToZonedInput(v, timezone()!);
|
|
59
|
+
}
|
|
40
60
|
// Convert ISO to local datetime-local format
|
|
41
61
|
const d = new Date(v);
|
|
42
62
|
if (dateOnly()) {
|
|
@@ -51,6 +71,11 @@ const DateTimeInput = (props: DateTimeInputProps) => {
|
|
|
51
71
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
52
72
|
};
|
|
53
73
|
|
|
74
|
+
const outputValue = (value: string) => {
|
|
75
|
+
if (!value || dateOnly() || !timezone()) return value;
|
|
76
|
+
return dates.zonedDateTimeToInstant(value, timezone()!, { disambiguation: "compatible" });
|
|
77
|
+
};
|
|
78
|
+
|
|
54
79
|
return (
|
|
55
80
|
<InputWrapper
|
|
56
81
|
label={props.label}
|
|
@@ -70,7 +95,7 @@ const DateTimeInput = (props: DateTimeInputProps) => {
|
|
|
70
95
|
type={dateOnly() ? "date" : "datetime-local"}
|
|
71
96
|
class={`input w-full pl-9 ${disabled() ? "cursor-not-allowed opacity-50" : ""}`}
|
|
72
97
|
value={inputValue()}
|
|
73
|
-
onChange={(e) => props.onChange?.(e.currentTarget.value)}
|
|
98
|
+
onChange={(e) => props.onChange?.(outputValue(e.currentTarget.value))}
|
|
74
99
|
disabled={disabled()}
|
|
75
100
|
aria-label={!props.label ? props.placeholder : undefined}
|
|
76
101
|
aria-describedby={a11y.ariaDescribedBy()}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { dropzone } from "@valentinkolb/stdlib/solid";
|
|
2
|
+
import { type Accessor, type JSX, Show } from "solid-js";
|
|
3
|
+
import { createInputA11y, InputWrapper } from "./util";
|
|
4
|
+
|
|
5
|
+
export type FileDropzoneProps = {
|
|
6
|
+
label?: string | JSX.Element;
|
|
7
|
+
description?: string | JSX.Element;
|
|
8
|
+
ariaLabel?: string;
|
|
9
|
+
accept?: string;
|
|
10
|
+
multiple?: boolean;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
busy?: boolean | Accessor<boolean>;
|
|
14
|
+
error?: string | Accessor<string | null | undefined>;
|
|
15
|
+
icon?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
subtitle?: string;
|
|
18
|
+
hint?: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
onDrop: (files: File[]) => void | Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const resolveMaybe = <T,>(value: T | Accessor<T> | undefined): T | undefined =>
|
|
24
|
+
typeof value === "function" ? (value as Accessor<T>)() : value;
|
|
25
|
+
|
|
26
|
+
export default function FileDropzone(props: FileDropzoneProps) {
|
|
27
|
+
let inputRef: HTMLInputElement | undefined;
|
|
28
|
+
const busy = () => resolveMaybe(props.busy) ?? false;
|
|
29
|
+
const disabled = () => (props.disabled ?? false) || busy();
|
|
30
|
+
const error = () => resolveMaybe(props.error) ?? undefined;
|
|
31
|
+
const a11y = createInputA11y({ description: props.description, error });
|
|
32
|
+
|
|
33
|
+
const emitFiles = (files: File[]) => {
|
|
34
|
+
if (disabled() || files.length === 0) return;
|
|
35
|
+
void props.onDrop(props.multiple === false ? files.slice(0, 1) : files);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const dz = dropzone.create({
|
|
39
|
+
accept: props.accept,
|
|
40
|
+
onDrop: emitFiles,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const zoneClass = () => {
|
|
44
|
+
const base =
|
|
45
|
+
"group relative flex min-h-28 w-full flex-col items-center justify-center gap-2 rounded-lg border px-4 py-5 text-center text-sm transition-[background-color,border-color,box-shadow,color] duration-150 focus-ui";
|
|
46
|
+
const enabled = disabled() ? "cursor-not-allowed opacity-60" : "cursor-pointer";
|
|
47
|
+
const state = dz.invalidDrag()
|
|
48
|
+
? "border-red-400 bg-red-50/80 text-red-700 dark:border-red-500/70 dark:bg-red-950/35 dark:text-red-200"
|
|
49
|
+
: dz.isDragging()
|
|
50
|
+
? "border-blue-400 bg-blue-50/80 text-blue-700 dark:border-blue-500/70 dark:bg-blue-950/35 dark:text-blue-200"
|
|
51
|
+
: "border-zinc-200/80 bg-zinc-50/80 text-secondary hover:border-zinc-300 hover:bg-white dark:border-zinc-800 dark:bg-zinc-900/55 dark:hover:border-zinc-700 dark:hover:bg-zinc-900";
|
|
52
|
+
|
|
53
|
+
return `${base} ${enabled} ${state} ${props.class ?? ""}`;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const title = () => {
|
|
57
|
+
if (busy()) return "Uploading...";
|
|
58
|
+
if (dz.invalidDrag()) return "File type not accepted";
|
|
59
|
+
if (dz.isDragging()) return "Drop to upload";
|
|
60
|
+
return props.title ?? "Drop files or click to choose";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const subtitle = () => {
|
|
64
|
+
if (dz.invalidDrag()) return "Choose a file that matches this field.";
|
|
65
|
+
return props.subtitle;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<InputWrapper
|
|
70
|
+
label={props.label}
|
|
71
|
+
description={props.description}
|
|
72
|
+
error={error()}
|
|
73
|
+
required={props.required}
|
|
74
|
+
inputId={a11y.inputId}
|
|
75
|
+
descriptionId={a11y.descriptionId}
|
|
76
|
+
errorId={a11y.errorId}
|
|
77
|
+
>
|
|
78
|
+
<button
|
|
79
|
+
id={a11y.inputId}
|
|
80
|
+
type="button"
|
|
81
|
+
class={zoneClass()}
|
|
82
|
+
onClick={() => inputRef?.click()}
|
|
83
|
+
disabled={disabled()}
|
|
84
|
+
aria-label={props.ariaLabel ?? (typeof props.label === "string" ? props.label : props.title)}
|
|
85
|
+
aria-describedby={a11y.ariaDescribedBy()}
|
|
86
|
+
{...dz.handlers}
|
|
87
|
+
>
|
|
88
|
+
<span class="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-lg text-blue-600 shadow-[var(--theme-shadow-elevated)] transition-colors group-hover:text-blue-700 dark:bg-zinc-950 dark:text-blue-300">
|
|
89
|
+
<i class={`ti ${busy() ? "ti-loader-2 animate-spin" : (props.icon ?? "ti-cloud-upload")}`} aria-hidden="true" />
|
|
90
|
+
</span>
|
|
91
|
+
<span class="flex flex-col gap-0.5">
|
|
92
|
+
<span class="font-medium text-primary">{title()}</span>
|
|
93
|
+
<Show when={subtitle()}>
|
|
94
|
+
<span class="text-xs text-dimmed">{subtitle()}</span>
|
|
95
|
+
</Show>
|
|
96
|
+
<Show when={props.hint}>
|
|
97
|
+
<span class="text-[11px] text-dimmed">{props.hint}</span>
|
|
98
|
+
</Show>
|
|
99
|
+
</span>
|
|
100
|
+
</button>
|
|
101
|
+
<input
|
|
102
|
+
ref={inputRef}
|
|
103
|
+
type="file"
|
|
104
|
+
class="hidden"
|
|
105
|
+
accept={props.accept}
|
|
106
|
+
multiple={props.multiple ?? true}
|
|
107
|
+
disabled={disabled()}
|
|
108
|
+
onChange={(event) => {
|
|
109
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
110
|
+
event.currentTarget.value = "";
|
|
111
|
+
emitFiles(files);
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
</InputWrapper>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { fuzzy } from "@valentinkolb/stdlib";
|
|
2
|
+
import { ICON_OPTIONS, type IconOption } from "../../shared/icons";
|
|
3
|
+
import SelectInput from "./Select";
|
|
4
|
+
|
|
5
|
+
type IconInputProps = {
|
|
6
|
+
label?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
/**
|
|
10
|
+
* The currently-selected icon class string (e.g. `"ti ti-currency-euro"`).
|
|
11
|
+
* Empty / undefined means "no icon picked". Stored as the full Tabler
|
|
12
|
+
* class so consumers can render directly via `<i class={value}>`
|
|
13
|
+
* without prepending `ti ` themselves; render sites that DO prepend
|
|
14
|
+
* produce a duplicate-token (`ti ti ti-foo`) which the browser
|
|
15
|
+
* tolerates as a no-op.
|
|
16
|
+
*/
|
|
17
|
+
value?: () => string | undefined;
|
|
18
|
+
onChange?: (next: string) => void;
|
|
19
|
+
error?: () => string | undefined;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
/** Default true — empty selection is a valid state for icons. */
|
|
22
|
+
clearable?: boolean;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Override the icon catalogue. Defaults to the curated `ICON_OPTIONS`
|
|
26
|
+
* exported from `cloud/shared/icons.ts`. Useful for app-specific
|
|
27
|
+
* sub-sets (e.g. only finance icons in a finance picker).
|
|
28
|
+
*/
|
|
29
|
+
options?: IconOption[];
|
|
30
|
+
/**
|
|
31
|
+
* How many results the fuzzy search returns at most. Default 50 —
|
|
32
|
+
* enough to scroll through, not so many that the dropdown becomes a
|
|
33
|
+
* wall of icons. Empty queries bypass this cap and show the full
|
|
34
|
+
* catalogue alphabetically.
|
|
35
|
+
*/
|
|
36
|
+
searchLimit?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Searchable icon picker — wraps `SelectInput` in `fetchData` mode and
|
|
41
|
+
* runs `fuzzy.filter` from `@valentinkolb/stdlib` over the catalogue
|
|
42
|
+
* locally. No network: the icon list is bundled, the "fetcher" is a
|
|
43
|
+
* synchronous filter wrapped in a Promise so it slots into
|
|
44
|
+
* SelectInput's async loader contract.
|
|
45
|
+
*
|
|
46
|
+
* Each icon entry carries a `keywords` synonym list — searching "money"
|
|
47
|
+
* matches `ti ti-currency-euro`, `ti ti-coin`, `ti ti-wallet`, etc.
|
|
48
|
+
* Symbol forms work too: typing `€` finds the Euro icon.
|
|
49
|
+
*
|
|
50
|
+
* Empty query (dropdown just opened, user hasn't typed) returns the
|
|
51
|
+
* full catalogue sorted alphabetically by label so the user can
|
|
52
|
+
* browse-not-search if they prefer.
|
|
53
|
+
*
|
|
54
|
+
* The picker stores the full Tabler class string as the value (e.g.
|
|
55
|
+
* `"ti ti-currency-euro"`). Render the selected icon with
|
|
56
|
+
* `<i class={value}>` — no need to add `ti ` yourself.
|
|
57
|
+
*/
|
|
58
|
+
export default function IconInput(props: IconInputProps) {
|
|
59
|
+
const options = () => props.options ?? ICON_OPTIONS;
|
|
60
|
+
const limit = () => props.searchLimit ?? 50;
|
|
61
|
+
|
|
62
|
+
// Pre-compute the searchable string per option once per render of
|
|
63
|
+
// the catalogue. Since the catalogue is a constant in the common
|
|
64
|
+
// case, this memoizes effectively across edit sessions.
|
|
65
|
+
const searchKey = (entry: IconOption) =>
|
|
66
|
+
[entry.label, ...entry.keywords].join(" ").toLowerCase();
|
|
67
|
+
|
|
68
|
+
const fetcher = (query: string): Promise<IconOption[]> => {
|
|
69
|
+
const trimmed = query.trim();
|
|
70
|
+
if (trimmed.length === 0) {
|
|
71
|
+
// No query: alphabetical-by-label, full catalogue. Browsers
|
|
72
|
+
// happily render hundreds of dropdown rows at this size; if it
|
|
73
|
+
// ever becomes a perf concern we'd switch to virtualisation
|
|
74
|
+
// rather than truncating the list.
|
|
75
|
+
const all = [...options()].sort((a, b) =>
|
|
76
|
+
a.label.localeCompare(b.label, undefined, { sensitivity: "base" }),
|
|
77
|
+
);
|
|
78
|
+
return Promise.resolve(all);
|
|
79
|
+
}
|
|
80
|
+
const matches = fuzzy.filter(trimmed.toLowerCase(), options(), {
|
|
81
|
+
key: searchKey,
|
|
82
|
+
limit: limit(),
|
|
83
|
+
});
|
|
84
|
+
return Promise.resolve(matches.map((m) => m.item));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Resolve the selected option's label so the trigger renders the
|
|
88
|
+
// friendly name (and dropdown glyph) immediately, even before the
|
|
89
|
+
// user opens the picker.
|
|
90
|
+
const selectedLabel = () => {
|
|
91
|
+
const v = props.value?.();
|
|
92
|
+
if (!v) return undefined;
|
|
93
|
+
return options().find((o) => o.id === v)?.label;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<SelectInput
|
|
98
|
+
label={props.label}
|
|
99
|
+
description={props.description}
|
|
100
|
+
placeholder={props.placeholder ?? "Pick an icon…"}
|
|
101
|
+
icon="ti ti-icons"
|
|
102
|
+
value={props.value}
|
|
103
|
+
onChange={props.onChange}
|
|
104
|
+
error={props.error}
|
|
105
|
+
required={props.required}
|
|
106
|
+
clearable={props.clearable ?? true}
|
|
107
|
+
disabled={props.disabled}
|
|
108
|
+
fetchData={fetcher}
|
|
109
|
+
selectedLabel={selectedLabel}
|
|
110
|
+
// No debounce: filtering is local + sub-millisecond, debouncing
|
|
111
|
+
// just adds latency. SelectInput's default 200ms is built for
|
|
112
|
+
// network calls.
|
|
113
|
+
fetchDebounceMs={0}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -14,6 +14,22 @@ type ImageInputProps = {
|
|
|
14
14
|
error?: () => string | undefined;
|
|
15
15
|
required?: boolean;
|
|
16
16
|
disabled?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Custom file→data-URL transform applied to the picked file before
|
|
19
|
+
* emitting via `onChange`. Default = `img.presets.avatar` which
|
|
20
|
+
* produces a 512×512 cropped WebP — fine for square avatars but
|
|
21
|
+
* the wrong shape for banners / title images. Pass a custom
|
|
22
|
+
* transform (e.g. one that preserves aspect ratio and caps the
|
|
23
|
+
* longest side) to override. Receives the user-picked `File`,
|
|
24
|
+
* returns a base64 data-URL string.
|
|
25
|
+
*/
|
|
26
|
+
transform?: (file: File) => Promise<string>;
|
|
27
|
+
/**
|
|
28
|
+
* File-picker `accept` attribute. Default matches the common
|
|
29
|
+
* raster formats the avatar preset handles. Override when a
|
|
30
|
+
* caller needs to allow / restrict different formats.
|
|
31
|
+
*/
|
|
32
|
+
accept?: string;
|
|
17
33
|
};
|
|
18
34
|
|
|
19
35
|
/**
|
|
@@ -42,8 +58,9 @@ const ImageInput = (props: ImageInputProps) => {
|
|
|
42
58
|
|
|
43
59
|
const selectImage = () => {
|
|
44
60
|
if (disabled()) return;
|
|
45
|
-
|
|
46
|
-
|
|
61
|
+
const transform = props.transform ?? ((f: File) => img.presets.avatar(f));
|
|
62
|
+
showFileDialog({ accept: props.accept ?? ".jpg,.jpeg,.png,.gif,.webp" })
|
|
63
|
+
.then(transform)
|
|
47
64
|
.then((image) => props.onChange?.(image));
|
|
48
65
|
};
|
|
49
66
|
|