@valentinkolb/cloud 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- package/src/ui/widgets/index.ts +9 -0
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from "./misc";
|
|
2
|
+
export * from "./ipa";
|
|
3
|
+
export * from "./input";
|
|
4
|
+
export * from "./filter";
|
|
5
|
+
export * from "./widgets";
|
|
6
|
+
export { currentPathWithQuery, refreshCurrentPath, navigateTo } from "./navigation";
|
|
7
|
+
export { SettingsField, SettingsSaveBar, sameSettingValue, readSettingsError } from "./admin-settings";
|
|
8
|
+
export type { SettingsFieldProps, SettingsSaveBarProps } from "./admin-settings";
|
|
9
|
+
export { prompts, DialogHeader, createFormState } from "./prompts";
|
|
10
|
+
export type { PromptSearchItem, PromptSearchInput, PromptSearchOptions } from "./prompts";
|
|
11
|
+
export { dialogCore, createDialogCore } from "./dialog-core";
|
|
12
|
+
export type { DialogClose, OpenDialogOptions, DialogRender, DialogCore } from "./dialog-core";
|
|
13
|
+
export { default as SidebarLayout, SidebarFromSpec } from "./sidebar";
|
|
14
|
+
export type { SidebarSpec, SidebarRow, SidebarSection, SidebarTreeNode, SidebarTreeSpec } from "./sidebar";
|
|
15
|
+
// NOTE: islands (*.island.tsx) belong inside the consuming app's package, not
|
|
16
|
+
// in cloud-lib. The SSR plugin discovers islands by import-path suffix; barrel
|
|
17
|
+
// re-exports strip the `.island` segment and silently break hydration. Apps
|
|
18
|
+
// that need a complex stateful component build their own admin/page islands
|
|
19
|
+
// using the input primitives exported above (TextInput, Switch, ImageInput, …).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CheckboxInputProps } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checkbox/Boolean input component
|
|
5
|
+
* @param label - Text displayed next to checkbox
|
|
6
|
+
* @param description - Optional description text below
|
|
7
|
+
* @param value - Reactive boolean value getter
|
|
8
|
+
* @param onChange - Called when checkbox state changes
|
|
9
|
+
* @param error - Reactive error message getter
|
|
10
|
+
* @param required - Show required asterisk
|
|
11
|
+
* @param disabled - Disable the checkbox
|
|
12
|
+
*/
|
|
13
|
+
const CheckboxInput = ({ label, description, value, onChange, error, required = false, disabled = false }: CheckboxInputProps) => {
|
|
14
|
+
const inputId = crypto.randomUUID();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div class="flex flex-col gap-2 select-none">
|
|
18
|
+
<div class="flex flex-row items-center gap-2">
|
|
19
|
+
<input
|
|
20
|
+
id={inputId}
|
|
21
|
+
type="checkbox"
|
|
22
|
+
checked={value?.() || false}
|
|
23
|
+
onChange={(e) => onChange?.(e.target.checked)}
|
|
24
|
+
disabled={disabled}
|
|
25
|
+
aria-required={required}
|
|
26
|
+
aria-invalid={!!error?.()}
|
|
27
|
+
aria-describedby={error?.() ? `${inputId}-error` : undefined}
|
|
28
|
+
class="h-4 w-4"
|
|
29
|
+
/>
|
|
30
|
+
{label && (
|
|
31
|
+
<label for={inputId} class={`text-xs select-none ${disabled ? "opacity-50" : "cursor-pointer"}`}>
|
|
32
|
+
{label}
|
|
33
|
+
{required && (
|
|
34
|
+
<span class="ml-0.5 text-red-500" aria-hidden="true">
|
|
35
|
+
*
|
|
36
|
+
</span>
|
|
37
|
+
)}
|
|
38
|
+
</label>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{description && <p class="text-dimmed ml-6 text-xs">{description}</p>}
|
|
43
|
+
|
|
44
|
+
{error?.() && (
|
|
45
|
+
<p id={`${inputId}-error`} class="ml-6 text-sm text-red-500" role="alert" aria-live="polite">
|
|
46
|
+
{error()}
|
|
47
|
+
</p>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export { CheckboxInput };
|
|
54
|
+
export const Checkbox = CheckboxInput;
|
|
55
|
+
export default CheckboxInput;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createSignal, Show } from "solid-js";
|
|
2
|
+
import { InputWrapper, createInputA11y } from "./util";
|
|
3
|
+
import type { ColorInputProps } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Color input component using native color picker
|
|
7
|
+
*/
|
|
8
|
+
const ColorInput = (props: ColorInputProps) => {
|
|
9
|
+
const disabled = () => props.disabled ?? false;
|
|
10
|
+
const compact = () => props.compact ?? !props.label;
|
|
11
|
+
const [isFocused, setIsFocused] = createSignal(false);
|
|
12
|
+
const inputId = crypto.randomUUID();
|
|
13
|
+
const a11y = createInputA11y({ description: props.description, error: props.error });
|
|
14
|
+
|
|
15
|
+
const currentColor = () => props.value?.() || "#3b82f6";
|
|
16
|
+
const isTransparent = () => props.isTransparent?.() ?? false;
|
|
17
|
+
|
|
18
|
+
// Compact version - just a clickable swatch
|
|
19
|
+
if (compact()) {
|
|
20
|
+
return (
|
|
21
|
+
<div class="relative inline-flex">
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
class={`w-7 h-7 border border-zinc-300 dark:border-zinc-600 rounded ${
|
|
25
|
+
disabled() ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-500"
|
|
26
|
+
}`}
|
|
27
|
+
style={`background-color: ${currentColor()}`}
|
|
28
|
+
onClick={() => document.getElementById(inputId)?.click()}
|
|
29
|
+
disabled={disabled()}
|
|
30
|
+
/>
|
|
31
|
+
<input
|
|
32
|
+
id={inputId}
|
|
33
|
+
type="color"
|
|
34
|
+
class="absolute opacity-0 w-0 h-0"
|
|
35
|
+
value={currentColor()}
|
|
36
|
+
onInput={(e) => props.onChange?.(e.currentTarget.value)}
|
|
37
|
+
onChange={(e) => props.onChange?.(e.currentTarget.value)}
|
|
38
|
+
disabled={disabled()}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Full version with label
|
|
45
|
+
return (
|
|
46
|
+
<InputWrapper
|
|
47
|
+
label={props.label}
|
|
48
|
+
description={props.description}
|
|
49
|
+
error={props.error?.()}
|
|
50
|
+
required={props.required}
|
|
51
|
+
inputId={a11y.inputId}
|
|
52
|
+
descriptionId={a11y.descriptionId}
|
|
53
|
+
errorId={a11y.errorId}
|
|
54
|
+
>
|
|
55
|
+
<div class="relative">
|
|
56
|
+
<div
|
|
57
|
+
class={`input flex items-center gap-2 transition-all ${isFocused() ? "border-blue-500 bg-white dark:border-blue-400 dark:bg-zinc-900" : ""} ${
|
|
58
|
+
disabled() || isTransparent() ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
|
59
|
+
}`}
|
|
60
|
+
onClick={() => {
|
|
61
|
+
if (!disabled() && !isTransparent()) {
|
|
62
|
+
document.getElementById(a11y.inputId)?.click();
|
|
63
|
+
}
|
|
64
|
+
}}
|
|
65
|
+
onKeyDown={(e) => {
|
|
66
|
+
if ((e.key === "Enter" || e.key === " ") && !disabled() && !isTransparent()) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
document.getElementById(a11y.inputId)?.click();
|
|
69
|
+
}
|
|
70
|
+
}}
|
|
71
|
+
role="button"
|
|
72
|
+
tabIndex={disabled() || isTransparent() ? -1 : 0}
|
|
73
|
+
>
|
|
74
|
+
<Show
|
|
75
|
+
when={!isTransparent()}
|
|
76
|
+
fallback={
|
|
77
|
+
<div class="h-4 w-4 shrink-0 border border-zinc-300 dark:border-zinc-600 bg-[repeating-conic-gradient(#ccc_0_25%,transparent_0_50%)] bg-size-[6px_6px] rounded" />
|
|
78
|
+
}
|
|
79
|
+
>
|
|
80
|
+
<div
|
|
81
|
+
class="h-4 w-4 shrink-0 border border-zinc-300 dark:border-zinc-600 rounded"
|
|
82
|
+
style={`background-color: ${currentColor()}`}
|
|
83
|
+
/>
|
|
84
|
+
</Show>
|
|
85
|
+
<span class="flex-1 font-mono text-sm uppercase leading-tight">{isTransparent() ? "transparent" : currentColor()}</span>
|
|
86
|
+
{props.transparent && (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
class={`shrink-0 flex items-center justify-center p-0.5 transition-colors rounded ${
|
|
90
|
+
isTransparent()
|
|
91
|
+
? "bg-zinc-200 dark:bg-zinc-700 text-primary font-medium"
|
|
92
|
+
: "text-dimmed hover:text-secondary hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
|
93
|
+
}`}
|
|
94
|
+
onClick={(e) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
props.onTransparentChange?.(!isTransparent());
|
|
97
|
+
}}
|
|
98
|
+
aria-label="Toggle transparent"
|
|
99
|
+
>
|
|
100
|
+
<i class="ti ti-grid-dots text-sm leading-none" />
|
|
101
|
+
</button>
|
|
102
|
+
)}
|
|
103
|
+
<input
|
|
104
|
+
id={a11y.inputId}
|
|
105
|
+
type="color"
|
|
106
|
+
class="absolute opacity-0 w-0 h-0"
|
|
107
|
+
value={currentColor()}
|
|
108
|
+
onInput={(e) => props.onChange?.(e.currentTarget.value)}
|
|
109
|
+
onChange={(e) => props.onChange?.(e.currentTarget.value)}
|
|
110
|
+
onFocus={() => setIsFocused(true)}
|
|
111
|
+
onBlur={() => setIsFocused(false)}
|
|
112
|
+
disabled={disabled() || isTransparent()}
|
|
113
|
+
aria-describedby={a11y.ariaDescribedBy()}
|
|
114
|
+
aria-invalid={!!props.error?.()}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</InputWrapper>
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export default ColorInput;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { InputWrapper, createInputA11y } from "./util";
|
|
2
|
+
|
|
3
|
+
type DateTimeInputProps = {
|
|
4
|
+
label?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
value?: () => string | undefined | null;
|
|
8
|
+
onChange?: (value: string) => void;
|
|
9
|
+
error?: () => string | undefined;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
/** Use date-only input instead of datetime-local */
|
|
13
|
+
dateOnly?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Date/DateTime input component using native browser inputs
|
|
18
|
+
* @param label - Optional label text
|
|
19
|
+
* @param description - Optional description text
|
|
20
|
+
* @param placeholder - Placeholder text (not shown in date inputs)
|
|
21
|
+
* @param value - Reactive value getter (ISO string or datetime-local format)
|
|
22
|
+
* @param onChange - Called on change event with datetime-local format string
|
|
23
|
+
* @param error - Reactive error message getter
|
|
24
|
+
* @param required - Show required asterisk after label
|
|
25
|
+
* @param disabled - Disable the input
|
|
26
|
+
* @param dateOnly - Use date input instead of datetime-local
|
|
27
|
+
*/
|
|
28
|
+
const DateTimeInput = (props: DateTimeInputProps) => {
|
|
29
|
+
const disabled = () => props.disabled ?? false;
|
|
30
|
+
const dateOnly = () => props.dateOnly ?? false;
|
|
31
|
+
const icon = () => (dateOnly() ? "ti ti-calendar" : "ti ti-calendar-time");
|
|
32
|
+
const a11y = createInputA11y({ description: props.description, error: props.error });
|
|
33
|
+
|
|
34
|
+
// Convert ISO string to input format if needed
|
|
35
|
+
const inputValue = () => {
|
|
36
|
+
const v = props.value?.();
|
|
37
|
+
if (!v) return "";
|
|
38
|
+
// If it's already in the right format, return as-is
|
|
39
|
+
if (!v.includes("Z") && !v.includes("+")) return v;
|
|
40
|
+
// Convert ISO to local datetime-local format
|
|
41
|
+
const d = new Date(v);
|
|
42
|
+
if (dateOnly()) {
|
|
43
|
+
return d.toISOString().slice(0, 10);
|
|
44
|
+
}
|
|
45
|
+
// Get local time
|
|
46
|
+
const year = d.getFullYear();
|
|
47
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
48
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
49
|
+
const hours = String(d.getHours()).padStart(2, "0");
|
|
50
|
+
const minutes = String(d.getMinutes()).padStart(2, "0");
|
|
51
|
+
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<InputWrapper
|
|
56
|
+
label={props.label}
|
|
57
|
+
description={props.description}
|
|
58
|
+
error={props.error?.()}
|
|
59
|
+
required={props.required}
|
|
60
|
+
inputId={a11y.inputId}
|
|
61
|
+
descriptionId={a11y.descriptionId}
|
|
62
|
+
errorId={a11y.errorId}
|
|
63
|
+
>
|
|
64
|
+
<div class="group relative">
|
|
65
|
+
<div class="absolute inset-y-0 left-3 z-10 flex items-center pointer-events-none text-zinc-400 dark:text-zinc-500">
|
|
66
|
+
<i class={`${icon()} group-focus-within:text-blue-500`} />
|
|
67
|
+
</div>
|
|
68
|
+
<input
|
|
69
|
+
id={a11y.inputId}
|
|
70
|
+
type={dateOnly() ? "date" : "datetime-local"}
|
|
71
|
+
class={`input w-full pl-9 ${disabled() ? "cursor-not-allowed opacity-50" : ""}`}
|
|
72
|
+
value={inputValue()}
|
|
73
|
+
onChange={(e) => props.onChange?.(e.currentTarget.value)}
|
|
74
|
+
disabled={disabled()}
|
|
75
|
+
aria-label={!props.label ? props.placeholder : undefined}
|
|
76
|
+
aria-describedby={a11y.ariaDescribedBy()}
|
|
77
|
+
aria-invalid={!!props.error?.()}
|
|
78
|
+
aria-required={props.required}
|
|
79
|
+
aria-disabled={disabled()}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
</InputWrapper>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default DateTimeInput;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { showFileDialog } from "@valentinkolb/stdlib/browser";
|
|
2
|
+
import { img } from "@valentinkolb/stdlib/browser";
|
|
3
|
+
import { Show } from "solid-js";
|
|
4
|
+
import { InputWrapper, createInputA11y } from "./util";
|
|
5
|
+
|
|
6
|
+
type ImageInputProps = {
|
|
7
|
+
label?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
ariaLabel?: string;
|
|
10
|
+
value?: () => string | null;
|
|
11
|
+
round?: boolean;
|
|
12
|
+
variant?: "default" | "small";
|
|
13
|
+
onChange?: (value: string | null) => void;
|
|
14
|
+
error?: () => string | undefined;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Image input component with file upload and preview
|
|
21
|
+
* @param label - Optional label text
|
|
22
|
+
* @param description - Optional description text
|
|
23
|
+
* @param ariaLabel - Accessibility label (defaults to label if not provided)
|
|
24
|
+
* @param value - Reactive string value getter (base64 or URL, fallback URLs are treated as null)
|
|
25
|
+
* @param onChange - Called when image changes (receives base64 string or null)
|
|
26
|
+
* @param error - Reactive error message getter
|
|
27
|
+
* @param round - Display image in circular shape
|
|
28
|
+
* @param variant - "default" for large preview, "small" for inline compact view
|
|
29
|
+
* @param required - Show required asterisk after label
|
|
30
|
+
* @param disabled - Disable the input
|
|
31
|
+
*/
|
|
32
|
+
const ImageInput = (props: ImageInputProps) => {
|
|
33
|
+
const disabled = () => (props.disabled ?? false) || !props.onChange;
|
|
34
|
+
const variant = () => props.variant ?? "default";
|
|
35
|
+
const a11y = createInputA11y({ description: props.description, error: props.error });
|
|
36
|
+
|
|
37
|
+
// Effective value: treat fallback URLs as null (no custom image set)
|
|
38
|
+
const value = () => {
|
|
39
|
+
const val = props.value?.();
|
|
40
|
+
return val && !val.includes("?fallback") ? val : null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const selectImage = () => {
|
|
44
|
+
if (disabled()) return;
|
|
45
|
+
showFileDialog({ accept: ".jpg,.jpeg,.png,.gif,.webp" })
|
|
46
|
+
.then((file) => img.presets.avatar(file))
|
|
47
|
+
.then((image) => props.onChange?.(image));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Small variant - inline compact view (same height as text input)
|
|
51
|
+
if (variant() === "small") {
|
|
52
|
+
return (
|
|
53
|
+
<InputWrapper
|
|
54
|
+
label={props.label}
|
|
55
|
+
description={props.description}
|
|
56
|
+
error={props.error?.()}
|
|
57
|
+
required={props.required}
|
|
58
|
+
inputId={a11y.inputId}
|
|
59
|
+
descriptionId={a11y.descriptionId}
|
|
60
|
+
errorId={a11y.errorId}
|
|
61
|
+
>
|
|
62
|
+
<div class="flex h-9 items-center gap-1" role="group" aria-labelledby={a11y.inputId} aria-describedby={a11y.ariaDescribedBy()}>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
class={`btn-secondary btn-sm h-9 w-9 shrink-0 overflow-hidden !p-0 ${props.round ? "rounded-full" : "rounded-lg"}`}
|
|
66
|
+
disabled
|
|
67
|
+
aria-hidden="true"
|
|
68
|
+
tabIndex={-1}
|
|
69
|
+
>
|
|
70
|
+
<Show
|
|
71
|
+
when={value()}
|
|
72
|
+
fallback={
|
|
73
|
+
<div class="flex h-full w-full items-center justify-center">
|
|
74
|
+
<i class="ti ti-photo-off opacity-65" aria-hidden="true" />
|
|
75
|
+
</div>
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
<img src={value()!} alt={props.label || "Selected image"} class="h-full w-full object-cover" />
|
|
79
|
+
</Show>
|
|
80
|
+
</button>
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
class="btn-secondary btn-sm flex h-9 w-9 items-center justify-center"
|
|
84
|
+
onClick={selectImage}
|
|
85
|
+
aria-label={value() ? "Change image" : "Add image"}
|
|
86
|
+
disabled={disabled()}
|
|
87
|
+
>
|
|
88
|
+
<i class={value() ? "ti ti-edit" : "ti ti-photo-plus"} aria-hidden="true" />
|
|
89
|
+
</button>
|
|
90
|
+
<Show when={value()}>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
class="btn-secondary btn-sm flex h-9 w-9 items-center justify-center"
|
|
94
|
+
onClick={() => props.onChange?.(null)}
|
|
95
|
+
aria-label="Remove image"
|
|
96
|
+
disabled={disabled()}
|
|
97
|
+
>
|
|
98
|
+
<i class="ti ti-trash" aria-hidden="true" />
|
|
99
|
+
</button>
|
|
100
|
+
</Show>
|
|
101
|
+
</div>
|
|
102
|
+
</InputWrapper>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Default variant - large preview
|
|
107
|
+
return (
|
|
108
|
+
<InputWrapper
|
|
109
|
+
label={props.label}
|
|
110
|
+
description={props.description}
|
|
111
|
+
error={props.error?.()}
|
|
112
|
+
required={props.required}
|
|
113
|
+
inputId={a11y.inputId}
|
|
114
|
+
descriptionId={a11y.descriptionId}
|
|
115
|
+
errorId={a11y.errorId}
|
|
116
|
+
>
|
|
117
|
+
<div class="flex flex-col items-center gap-1" role="group" aria-labelledby={a11y.inputId} aria-describedby={a11y.ariaDescribedBy()}>
|
|
118
|
+
<div
|
|
119
|
+
class={`h-30 w-30 self-center overflow-hidden border-2 border-zinc-200 md:h-50 md:w-50 dark:border-zinc-700 ${
|
|
120
|
+
props.round ? "rounded-full" : "rounded-2xl"
|
|
121
|
+
}`}
|
|
122
|
+
>
|
|
123
|
+
<Show
|
|
124
|
+
when={value()}
|
|
125
|
+
fallback={
|
|
126
|
+
<div class="flex h-full w-full items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
|
127
|
+
<i class="ti ti-photo-off text-2xl text-zinc-400 dark:text-zinc-600" aria-hidden="true" />
|
|
128
|
+
</div>
|
|
129
|
+
}
|
|
130
|
+
>
|
|
131
|
+
<img
|
|
132
|
+
src={value()!}
|
|
133
|
+
alt={props.label || "Selected image"}
|
|
134
|
+
class="h-full w-full object-cover"
|
|
135
|
+
aria-label={props.ariaLabel || props.label || "Selected image"}
|
|
136
|
+
/>
|
|
137
|
+
</Show>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div class="mb-4 flex flex-row items-center gap-2 self-center">
|
|
141
|
+
<Show when={value()}>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
class="btn-simple btn-sm"
|
|
145
|
+
onClick={() => props.onChange?.(null)}
|
|
146
|
+
aria-label="Remove image"
|
|
147
|
+
disabled={disabled()}
|
|
148
|
+
>
|
|
149
|
+
<i class="ti ti-trash" aria-hidden="true" />
|
|
150
|
+
Remove
|
|
151
|
+
</button>
|
|
152
|
+
</Show>
|
|
153
|
+
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
class="btn-simple btn-sm"
|
|
157
|
+
onClick={selectImage}
|
|
158
|
+
aria-label={value() ? "Change image" : "Add image"}
|
|
159
|
+
disabled={disabled()}
|
|
160
|
+
>
|
|
161
|
+
<i class="ti ti-photo-plus" aria-hidden="true" />
|
|
162
|
+
{value() ? "Change" : "Add"}
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</InputWrapper>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export default ImageInput;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { InputWrapper, createInputA11y } from "./util";
|
|
2
|
+
|
|
3
|
+
type NumberInputProps = {
|
|
4
|
+
label?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
value?: () => number | undefined;
|
|
8
|
+
onChange?: (value: number) => void;
|
|
9
|
+
onInput?: (value: number) => void;
|
|
10
|
+
error?: () => string | undefined;
|
|
11
|
+
max?: number;
|
|
12
|
+
min?: number;
|
|
13
|
+
step?: number;
|
|
14
|
+
required?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Number input component with increment/decrement buttons
|
|
20
|
+
* @param label - Optional label text
|
|
21
|
+
* @param description - Optional description text
|
|
22
|
+
* @param placeholder - Placeholder text
|
|
23
|
+
* @param value - Reactive number value getter
|
|
24
|
+
* @param onChange - Called on change event
|
|
25
|
+
* @param onInput - Called on input event
|
|
26
|
+
* @param error - Reactive error message getter
|
|
27
|
+
* @param max - Maximum allowed value (default: Infinity)
|
|
28
|
+
* @param min - Minimum allowed value (default: -Infinity)
|
|
29
|
+
* @param step - Step increment/decrement amount (default: 1)
|
|
30
|
+
* @param required - Show required asterisk after label
|
|
31
|
+
* @param disabled - Disable the input
|
|
32
|
+
*/
|
|
33
|
+
const NumberInput = (props: NumberInputProps) => {
|
|
34
|
+
const value = () => props.value?.() ?? 0;
|
|
35
|
+
const max = () => props.max ?? Infinity;
|
|
36
|
+
const min = () => props.min ?? -Infinity;
|
|
37
|
+
const step = () => props.step ?? 1;
|
|
38
|
+
const disabled = () => props.disabled ?? false;
|
|
39
|
+
const a11y = createInputA11y({ description: props.description, error: props.error });
|
|
40
|
+
|
|
41
|
+
const parse = (val: string, applyConstraints: boolean = true) => {
|
|
42
|
+
const parsed = parseInt(val);
|
|
43
|
+
if (isNaN(parsed)) return min();
|
|
44
|
+
return applyConstraints ? Math.max(min(), Math.min(max(), parsed)) : parsed;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<InputWrapper
|
|
49
|
+
label={props.label}
|
|
50
|
+
description={props.description}
|
|
51
|
+
error={props.error?.()}
|
|
52
|
+
required={props.required}
|
|
53
|
+
inputId={a11y.inputId}
|
|
54
|
+
descriptionId={a11y.descriptionId}
|
|
55
|
+
errorId={a11y.errorId}
|
|
56
|
+
>
|
|
57
|
+
<div class={`flex flex-row flex-nowrap gap-3 text-nowrap ${disabled() ? "opacity-50" : ""}`}>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
class={`input ti ti-minus px-3 cursor-pointer hover:text-primary ${value() <= min() && "opacity-40"}`}
|
|
61
|
+
aria-label="Decrease value"
|
|
62
|
+
onClick={() => {
|
|
63
|
+
const v = Math.max(min(), value() - step());
|
|
64
|
+
props.onChange?.(v);
|
|
65
|
+
props.onInput?.(v);
|
|
66
|
+
}}
|
|
67
|
+
disabled={disabled() || value() <= min()}
|
|
68
|
+
/>
|
|
69
|
+
<div class="group relative flex-1">
|
|
70
|
+
<input
|
|
71
|
+
id={a11y.inputId}
|
|
72
|
+
type="number"
|
|
73
|
+
class={`input w-full text-center font-mono font-semibold ${disabled() ? "cursor-not-allowed" : ""}`}
|
|
74
|
+
placeholder={props.placeholder}
|
|
75
|
+
value={value()}
|
|
76
|
+
onChange={(e) => {
|
|
77
|
+
const v = parse(e.currentTarget.value, true);
|
|
78
|
+
props.onChange?.(v);
|
|
79
|
+
e.currentTarget.value = `${v}`;
|
|
80
|
+
}}
|
|
81
|
+
onInput={(e) => {
|
|
82
|
+
const v = parse(e.currentTarget.value, false);
|
|
83
|
+
props.onInput?.(v);
|
|
84
|
+
}}
|
|
85
|
+
disabled={disabled()}
|
|
86
|
+
aria-label={!props.label ? props.placeholder || "Enter number" : undefined}
|
|
87
|
+
aria-describedby={a11y.ariaDescribedBy()}
|
|
88
|
+
aria-invalid={!!props.error?.()}
|
|
89
|
+
aria-required={props.required}
|
|
90
|
+
aria-disabled={disabled()}
|
|
91
|
+
aria-valuemin={min()}
|
|
92
|
+
aria-valuemax={max()}
|
|
93
|
+
aria-valuenow={value()}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
class={`input ti ti-plus px-3 cursor-pointer hover:text-primary ${value() >= max() && "opacity-40"}`}
|
|
100
|
+
aria-label="Increase value"
|
|
101
|
+
onClick={() => {
|
|
102
|
+
const v = Math.min(max(), value() + step());
|
|
103
|
+
props.onChange?.(v);
|
|
104
|
+
props.onInput?.(v);
|
|
105
|
+
}}
|
|
106
|
+
disabled={disabled() || value() >= max()}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
</InputWrapper>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export default NumberInput;
|