@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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
type SliderProps = {
|
|
2
|
+
label?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
value: () => number;
|
|
5
|
+
onChange: (value: number) => void;
|
|
6
|
+
min?: number;
|
|
7
|
+
max?: number;
|
|
8
|
+
step?: number;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
showValue?: boolean;
|
|
11
|
+
formatValue?: (value: number) => string;
|
|
12
|
+
/** When true, the track fill originates from the center instead of the left edge. */
|
|
13
|
+
center?: boolean;
|
|
14
|
+
/** Value to reset to on double-click. Defaults to center of range (if center) or min. */
|
|
15
|
+
defaultValue?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Range slider input component.
|
|
20
|
+
*/
|
|
21
|
+
const Slider = ({
|
|
22
|
+
label,
|
|
23
|
+
description,
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
min = 0,
|
|
27
|
+
max = 100,
|
|
28
|
+
step = 1,
|
|
29
|
+
disabled = false,
|
|
30
|
+
showValue = true,
|
|
31
|
+
formatValue = (v) => String(v),
|
|
32
|
+
center = false,
|
|
33
|
+
defaultValue,
|
|
34
|
+
}: SliderProps) => {
|
|
35
|
+
const resetValue = defaultValue ?? (center ? (min + max) / 2 : min);
|
|
36
|
+
const inputId = crypto.randomUUID();
|
|
37
|
+
const descId = description ? `${inputId}-desc` : undefined;
|
|
38
|
+
|
|
39
|
+
const percentage = () => ((value() - min) / (max - min)) * 100;
|
|
40
|
+
|
|
41
|
+
const trackBackground = () => {
|
|
42
|
+
const p = percentage();
|
|
43
|
+
const fill = "var(--slider-fill)";
|
|
44
|
+
const track = "var(--slider-track)";
|
|
45
|
+
|
|
46
|
+
if (center) {
|
|
47
|
+
const lo = Math.min(50, p);
|
|
48
|
+
const hi = Math.max(50, p);
|
|
49
|
+
return `linear-gradient(to right, ${track} 0%, ${track} ${lo}%, ${fill} ${lo}%, ${fill} ${hi}%, ${track} ${hi}%, ${track} 100%)`;
|
|
50
|
+
}
|
|
51
|
+
return `linear-gradient(to right, ${fill} 0%, ${fill} ${p}%, ${track} ${p}%, ${track} 100%)`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div class="flex flex-col gap-1 slider-track-colors" classList={{ "opacity-50": disabled }}>
|
|
56
|
+
{(label || showValue) && (
|
|
57
|
+
<div class="flex items-center justify-between text-xs">
|
|
58
|
+
{label && (
|
|
59
|
+
<label for={inputId} class="text-secondary">
|
|
60
|
+
{label}
|
|
61
|
+
</label>
|
|
62
|
+
)}
|
|
63
|
+
{showValue && <span class="text-dimmed tabular-nums">{formatValue(value())}</span>}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
{description && (
|
|
67
|
+
<p id={descId} class="text-xs text-dimmed -mt-0.5">
|
|
68
|
+
{description}
|
|
69
|
+
</p>
|
|
70
|
+
)}
|
|
71
|
+
<input
|
|
72
|
+
id={inputId}
|
|
73
|
+
type="range"
|
|
74
|
+
min={min}
|
|
75
|
+
max={max}
|
|
76
|
+
step={step}
|
|
77
|
+
value={value()}
|
|
78
|
+
onInput={(e) => onChange(Number(e.currentTarget.value))}
|
|
79
|
+
onDblClick={() => onChange(resetValue)}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
aria-describedby={descId}
|
|
82
|
+
class="w-full h-1.5 appearance-none cursor-pointer
|
|
83
|
+
rounded-full
|
|
84
|
+
[&::-webkit-slider-thumb]:appearance-none
|
|
85
|
+
[&::-webkit-slider-thumb]:w-3.5
|
|
86
|
+
[&::-webkit-slider-thumb]:h-3.5
|
|
87
|
+
[&::-webkit-slider-thumb]:cursor-pointer
|
|
88
|
+
[&::-webkit-slider-thumb]:transition-transform
|
|
89
|
+
[&::-webkit-slider-thumb]:rounded-full
|
|
90
|
+
[&::-webkit-slider-thumb]:bg-blue-500
|
|
91
|
+
[&::-webkit-slider-thumb]:dark:bg-blue-400
|
|
92
|
+
[&::-webkit-slider-thumb]:shadow-sm
|
|
93
|
+
[&::-webkit-slider-thumb]:hover:scale-110
|
|
94
|
+
]:rounded-none
|
|
95
|
+
]:bg-(--slider-fill)
|
|
96
|
+
[&::-moz-range-thumb]:w-3.5
|
|
97
|
+
[&::-moz-range-thumb]:h-3.5
|
|
98
|
+
[&::-moz-range-thumb]:border-0
|
|
99
|
+
[&::-moz-range-thumb]:cursor-pointer
|
|
100
|
+
[&::-moz-range-thumb]:rounded-full
|
|
101
|
+
[&::-moz-range-thumb]:bg-blue-500
|
|
102
|
+
[&::-moz-range-thumb]:dark:bg-blue-400
|
|
103
|
+
]:rounded-none
|
|
104
|
+
]:bg-(--slider-fill)
|
|
105
|
+
focus-visible:outline-none
|
|
106
|
+
focus-visible:[&::-webkit-slider-thumb]:ring-2
|
|
107
|
+
focus-visible:[&::-webkit-slider-thumb]:ring-zinc-400
|
|
108
|
+
focus-visible:[&::-webkit-slider-thumb]:ring-offset-2
|
|
109
|
+
disabled:cursor-not-allowed
|
|
110
|
+
disabled:[&::-webkit-slider-thumb]:bg-zinc-400
|
|
111
|
+
disabled:[&::-moz-range-thumb]:bg-zinc-400"
|
|
112
|
+
style={{ background: trackBackground() }}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export default Slider;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SwitchInputProps } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Toggle switch component - accessible via hidden checkbox
|
|
5
|
+
*/
|
|
6
|
+
const Switch = ({ label, value, onChange, disabled = false }: SwitchInputProps) => {
|
|
7
|
+
const inputId = crypto.randomUUID();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<label
|
|
11
|
+
for={inputId}
|
|
12
|
+
class={`inline-flex items-center gap-2 select-none ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
|
13
|
+
>
|
|
14
|
+
{/* Hidden checkbox for accessibility */}
|
|
15
|
+
<input
|
|
16
|
+
id={inputId}
|
|
17
|
+
type="checkbox"
|
|
18
|
+
checked={value?.() || false}
|
|
19
|
+
onChange={(e) => onChange?.(e.target.checked)}
|
|
20
|
+
disabled={disabled}
|
|
21
|
+
class="sr-only peer"
|
|
22
|
+
/>
|
|
23
|
+
{/* Visual switch track */}
|
|
24
|
+
<span
|
|
25
|
+
class={`
|
|
26
|
+
relative transition-colors
|
|
27
|
+
w-9 h-5 rounded-full
|
|
28
|
+
|
|
29
|
+
bg-zinc-200 dark:bg-zinc-600/40
|
|
30
|
+
peer-checked:bg-blue-500
|
|
31
|
+
|
|
32
|
+
peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500 peer-focus-visible:ring-offset-2
|
|
33
|
+
|
|
34
|
+
peer-disabled:opacity-50
|
|
35
|
+
`}
|
|
36
|
+
>
|
|
37
|
+
{/* Switch knob */}
|
|
38
|
+
<span
|
|
39
|
+
class={`
|
|
40
|
+
absolute transition-transform flex items-center justify-center
|
|
41
|
+
top-0.5 left-0.5 w-4 h-4 rounded-full
|
|
42
|
+
|
|
43
|
+
bg-white shadow-sm
|
|
44
|
+
|
|
45
|
+
`}
|
|
46
|
+
classList={{
|
|
47
|
+
"translate-x-4": value?.(),
|
|
48
|
+
"": value?.(),
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{/* Checkmark icon in terminal mode when checked */}
|
|
52
|
+
<i class={`ti ti-check hidden text-[8px] leading-none ${value?.() ? " " : "text-transparent"}`} />
|
|
53
|
+
</span>
|
|
54
|
+
</span>
|
|
55
|
+
{label && <span class="text-xs text-secondary select-none">{label}</span>}
|
|
56
|
+
</label>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export { Switch };
|
|
61
|
+
export const SwitchInput = Switch;
|
|
62
|
+
export default Switch;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { InputWrapper, createInputA11y } from "./util";
|
|
2
|
+
|
|
3
|
+
type TagsInputProps = {
|
|
4
|
+
label?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
icon?: string;
|
|
8
|
+
activeIcon?: string;
|
|
9
|
+
value?: () => string[];
|
|
10
|
+
onChange?: (tags: string[]) => void;
|
|
11
|
+
error?: () => string | undefined;
|
|
12
|
+
required?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const TagsInput = (props: TagsInputProps) => {
|
|
17
|
+
const placeholder = () => props.placeholder ?? "Tags (e.g. Tag 1, Tag 2,...)";
|
|
18
|
+
const icon = () => props.icon ?? "ti ti-tag";
|
|
19
|
+
const activeIcon = () => props.activeIcon ?? "ti ti-pencil";
|
|
20
|
+
const value = () => props.value?.() ?? [];
|
|
21
|
+
const disabled = () => props.disabled ?? false;
|
|
22
|
+
const a11y = createInputA11y({ description: props.description, error: props.error });
|
|
23
|
+
const announcementId = crypto.randomUUID();
|
|
24
|
+
|
|
25
|
+
const normalizeTag = (value: string) => value.replace(/\s+/g, " ").trim();
|
|
26
|
+
|
|
27
|
+
const escapeHtml = (value: string) =>
|
|
28
|
+
value
|
|
29
|
+
.replaceAll("&", "&")
|
|
30
|
+
.replaceAll("<", "<")
|
|
31
|
+
.replaceAll(">", ">")
|
|
32
|
+
.replaceAll('"', """)
|
|
33
|
+
.replaceAll("'", "'");
|
|
34
|
+
|
|
35
|
+
const renderTags = (tags: string[]) => {
|
|
36
|
+
if (tags.length === 0) return `<span class="text-zinc-400 dark:text-zinc-500">${placeholder()}</span>`;
|
|
37
|
+
return `<span contenteditable="false" class="flex flex-wrap items-center gap-1 pointer-events-none">${tags
|
|
38
|
+
.map(
|
|
39
|
+
(tag) =>
|
|
40
|
+
`<span class="inline-flex max-w-37.5 shrink-0 items-center overflow-hidden text-ellipsis whitespace-nowrap rounded px-1.5 text-xs leading-5 bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300">${escapeHtml(tag.trim())}</span>`,
|
|
41
|
+
)
|
|
42
|
+
.join("")}</span>`;
|
|
43
|
+
};
|
|
44
|
+
|
|
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="group relative flex">
|
|
56
|
+
<div class={`absolute left-3 inset-y-0 items-center z-10 flex pointer-events-none text-zinc-400 dark:text-zinc-500`}>
|
|
57
|
+
<i class={`${icon()} group-focus-within:hidden`} />
|
|
58
|
+
<i class={`${activeIcon()} hidden text-blue-500 group-focus-within:block`} />
|
|
59
|
+
</div>
|
|
60
|
+
<div
|
|
61
|
+
contentEditable={!disabled()}
|
|
62
|
+
id={a11y.inputId}
|
|
63
|
+
class={`input w-full pl-9 outline-none ${disabled() ? "cursor-not-allowed opacity-50" : "cursor-text"}`}
|
|
64
|
+
role="textbox"
|
|
65
|
+
aria-multiline="false"
|
|
66
|
+
aria-label={!props.label ? placeholder() || "Enter tags" : undefined}
|
|
67
|
+
aria-describedby={a11y.ariaDescribedBy()}
|
|
68
|
+
aria-invalid={!!props.error?.()}
|
|
69
|
+
aria-required={props.required}
|
|
70
|
+
aria-disabled={disabled()}
|
|
71
|
+
aria-placeholder={placeholder()}
|
|
72
|
+
onFocus={(e) => {
|
|
73
|
+
if (disabled()) return;
|
|
74
|
+
const currentTags = value();
|
|
75
|
+
e.currentTarget.textContent = currentTags.join(", ");
|
|
76
|
+
const sel = getSelection();
|
|
77
|
+
sel?.selectAllChildren(e.currentTarget);
|
|
78
|
+
sel?.collapseToEnd();
|
|
79
|
+
}}
|
|
80
|
+
onBlur={(e) => {
|
|
81
|
+
if (disabled()) return;
|
|
82
|
+
const oldTags = value();
|
|
83
|
+
const newTags = (e.currentTarget.textContent || "")
|
|
84
|
+
.split(",")
|
|
85
|
+
.map(normalizeTag)
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.filter((tag, index, self) => self.indexOf(tag) === index);
|
|
88
|
+
|
|
89
|
+
const added = newTags.filter((t) => !oldTags.includes(t));
|
|
90
|
+
const removed = oldTags.filter((t) => !newTags.includes(t));
|
|
91
|
+
|
|
92
|
+
if (added.length > 0 || removed.length > 0) {
|
|
93
|
+
const announcement = document.getElementById(announcementId);
|
|
94
|
+
if (announcement) {
|
|
95
|
+
let message = "";
|
|
96
|
+
if (added.length > 0) message += `Tags added: ${added.join(", ")}. `;
|
|
97
|
+
if (removed.length > 0) message += `Tags removed: ${removed.join(", ")}.`;
|
|
98
|
+
announcement.textContent = message;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
props.onChange?.(newTags);
|
|
103
|
+
e.currentTarget.innerHTML = renderTags(newTags);
|
|
104
|
+
}}
|
|
105
|
+
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), e.currentTarget.blur())}
|
|
106
|
+
innerHTML={renderTags(value())}
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
<div id={announcementId} class="sr-only" role="status" aria-live="polite" aria-atomic="true" />
|
|
110
|
+
</div>
|
|
111
|
+
</InputWrapper>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export default TagsInput;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
import { InputWrapper, createInputA11y } from "./util";
|
|
3
|
+
|
|
4
|
+
type TextInputProps = {
|
|
5
|
+
name?: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
ariaLabel?: string;
|
|
10
|
+
type?: "text" | "search" | "email" | "url" | "tel";
|
|
11
|
+
icon?: string;
|
|
12
|
+
activeIcon?: string;
|
|
13
|
+
value?: () => string | undefined | null;
|
|
14
|
+
onChange?: (value: string) => void;
|
|
15
|
+
onInput?: (value: string) => void;
|
|
16
|
+
clearable?: boolean;
|
|
17
|
+
onClear?: () => void;
|
|
18
|
+
clearLabel?: string;
|
|
19
|
+
error?: () => string | undefined;
|
|
20
|
+
multiline?: boolean;
|
|
21
|
+
required?: boolean;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
password?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Enable markdown mode.
|
|
26
|
+
* When true, automatically enables multiline mode and sets default icon to markdown.
|
|
27
|
+
*/
|
|
28
|
+
markdown?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Called when Enter is pressed (without Shift/Cmd) in multiline mode.
|
|
31
|
+
* Useful for submitting forms with Enter while keeping Shift+Enter for newlines.
|
|
32
|
+
*/
|
|
33
|
+
onSubmit?: () => void;
|
|
34
|
+
/** Approximate visible lines for multiline mode. Overrides default height. */
|
|
35
|
+
lines?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Text input component with optional multiline support
|
|
40
|
+
* @param label - Optional label text
|
|
41
|
+
* @param description - Optional description text
|
|
42
|
+
* @param placeholder - Placeholder text
|
|
43
|
+
* @param icon - Icon shown when not focused
|
|
44
|
+
* @param activeIcon - Icon shown when focused
|
|
45
|
+
* @param value - Reactive value getter
|
|
46
|
+
* @param onChange - Called on change event
|
|
47
|
+
* @param onInput - Called on input event
|
|
48
|
+
* @param error - Reactive error message getter
|
|
49
|
+
* @param multiline - Enable textarea mode
|
|
50
|
+
* @param required - Show required asterisk after label
|
|
51
|
+
* @param disabled - Disable the input
|
|
52
|
+
* @param markdown - Enable markdown mode (implies multiline, shows markdown icon)
|
|
53
|
+
*/
|
|
54
|
+
const TextInput = (props: TextInputProps) => {
|
|
55
|
+
const markdown = () => props.markdown ?? false;
|
|
56
|
+
const icon = () => props.icon ?? (markdown() ? "ti ti-markdown" : "ti ti-cursor-text");
|
|
57
|
+
const activeIcon = () => props.activeIcon ?? "ti ti-pencil";
|
|
58
|
+
const multiline = () => props.multiline ?? markdown(); // markdown implies multiline
|
|
59
|
+
const disabled = () => props.disabled ?? false;
|
|
60
|
+
const canClear = () => props.clearable && !multiline() && !props.password && !disabled();
|
|
61
|
+
const currentValue = () => props.value?.() ?? "";
|
|
62
|
+
const hasValue = () => currentValue().length > 0;
|
|
63
|
+
const [showPassword, setShowPassword] = createSignal(false);
|
|
64
|
+
const a11y = createInputA11y({ description: props.description, error: props.error, inputId: props.name });
|
|
65
|
+
|
|
66
|
+
const handleClear = () => {
|
|
67
|
+
if (props.onClear) {
|
|
68
|
+
props.onClear();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
props.onInput?.("");
|
|
72
|
+
props.onChange?.("");
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<InputWrapper
|
|
77
|
+
label={props.label}
|
|
78
|
+
description={props.description}
|
|
79
|
+
error={props.error?.()}
|
|
80
|
+
required={props.required}
|
|
81
|
+
inputId={a11y.inputId}
|
|
82
|
+
descriptionId={a11y.descriptionId}
|
|
83
|
+
errorId={a11y.errorId}
|
|
84
|
+
>
|
|
85
|
+
<div class="group relative flex">
|
|
86
|
+
<div
|
|
87
|
+
class={`absolute left-3 z-10 flex pointer-events-none text-zinc-400 dark:text-zinc-500 ${
|
|
88
|
+
multiline() ? "top-2.5" : "inset-y-0 items-center"
|
|
89
|
+
}`}
|
|
90
|
+
>
|
|
91
|
+
<i class={`${icon()} group-focus-within:hidden`} />
|
|
92
|
+
<i class={`${activeIcon()} hidden text-blue-500 group-focus-within:block`} />
|
|
93
|
+
</div>
|
|
94
|
+
{multiline() ? (
|
|
95
|
+
<textarea
|
|
96
|
+
id={a11y.inputId}
|
|
97
|
+
name={props.name}
|
|
98
|
+
class={`input w-full pl-9 ${disabled() ? "cursor-not-allowed opacity-50" : ""}`}
|
|
99
|
+
style={props.lines ? `min-height: ${props.lines * 1.5}em; max-height: ${Math.max(props.lines * 1.5, 20)}em` : "min-height: 3.75rem; height: 5rem; max-height: 12.5rem"}
|
|
100
|
+
placeholder={props.placeholder}
|
|
101
|
+
value={props.value?.() ?? ""}
|
|
102
|
+
onChange={(e) => props.onChange?.(e.target.value)}
|
|
103
|
+
onInput={(e) => props.onInput?.(e.target.value)}
|
|
104
|
+
onKeyDown={(e) => {
|
|
105
|
+
if (props.onSubmit && e.key === "Enter" && !e.shiftKey && !e.metaKey) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
props.onSubmit();
|
|
108
|
+
}
|
|
109
|
+
}}
|
|
110
|
+
disabled={disabled()}
|
|
111
|
+
aria-label={!props.label ? (props.ariaLabel ?? props.placeholder) : undefined}
|
|
112
|
+
aria-describedby={a11y.ariaDescribedBy()}
|
|
113
|
+
aria-invalid={!!props.error?.()}
|
|
114
|
+
aria-required={props.required}
|
|
115
|
+
aria-disabled={disabled()}
|
|
116
|
+
/>
|
|
117
|
+
) : (
|
|
118
|
+
<input
|
|
119
|
+
id={a11y.inputId}
|
|
120
|
+
name={props.name}
|
|
121
|
+
type={props.password && !showPassword() ? "password" : (props.type ?? "text")}
|
|
122
|
+
class={`input w-full pl-9 ${props.password || canClear() ? "pr-9" : ""} ${disabled() ? "cursor-not-allowed opacity-50" : ""}`}
|
|
123
|
+
placeholder={props.placeholder}
|
|
124
|
+
value={currentValue()}
|
|
125
|
+
onChange={(e) => props.onChange?.(e.target.value)}
|
|
126
|
+
onInput={(e) => props.onInput?.(e.target.value)}
|
|
127
|
+
disabled={disabled()}
|
|
128
|
+
aria-label={!props.label ? (props.ariaLabel ?? props.placeholder) : undefined}
|
|
129
|
+
aria-describedby={a11y.ariaDescribedBy()}
|
|
130
|
+
aria-invalid={!!props.error?.()}
|
|
131
|
+
aria-required={props.required}
|
|
132
|
+
aria-disabled={disabled()}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
{canClear() && hasValue() && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
class="absolute inset-y-0 right-3 flex items-center text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300"
|
|
139
|
+
onClick={handleClear}
|
|
140
|
+
aria-label={props.clearLabel ?? "Clear input"}
|
|
141
|
+
>
|
|
142
|
+
<i class="ti ti-x" />
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
{props.password && !multiline() && (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
class="absolute inset-y-0 right-3 flex items-center text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300"
|
|
149
|
+
onClick={() => setShowPassword(!showPassword())}
|
|
150
|
+
tabIndex={-1}
|
|
151
|
+
>
|
|
152
|
+
<i class={showPassword() ? "ti ti-eye-off" : "ti ti-eye"} />
|
|
153
|
+
</button>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</InputWrapper>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export default TextInput;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { default as TextInput } from "./TextInput";
|
|
2
|
+
export { default as NumberInput } from "./NumberInput";
|
|
3
|
+
export { Checkbox, CheckboxInput } from "./Checkbox";
|
|
4
|
+
export { Select, SelectInput } from "./Select";
|
|
5
|
+
export { default as SelectChip } from "./SelectChip";
|
|
6
|
+
export { Switch, SwitchInput } from "./Switch";
|
|
7
|
+
export { default as DateTimeInput } from "./DateTimeInput";
|
|
8
|
+
export { default as SegmentedControl } from "./SegmentedControl";
|
|
9
|
+
export { default as ColorInput } from "./ColorInput";
|
|
10
|
+
export { default as TagsInput } from "./TagsInput";
|
|
11
|
+
export { default as PinInput } from "./PinInput";
|
|
12
|
+
export { default as ImageInput } from "./ImageInput";
|
|
13
|
+
export { default as Slider } from "./Slider";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared input component prop types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Base props shared by all input components */
|
|
8
|
+
export type BaseInputProps = {
|
|
9
|
+
label?: string | JSX.Element;
|
|
10
|
+
description?: string;
|
|
11
|
+
error?: () => string | undefined;
|
|
12
|
+
required?: boolean;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Props for checkbox/toggle inputs */
|
|
17
|
+
export type CheckboxInputProps = BaseInputProps & {
|
|
18
|
+
value?: () => boolean | undefined;
|
|
19
|
+
onChange?: (checked: boolean) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Props for switch/toggle inputs */
|
|
23
|
+
export type SwitchInputProps = {
|
|
24
|
+
label?: string;
|
|
25
|
+
value?: () => boolean;
|
|
26
|
+
onChange?: (checked: boolean) => void;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Props for color input */
|
|
31
|
+
export type ColorInputProps = BaseInputProps & {
|
|
32
|
+
value?: () => string | undefined;
|
|
33
|
+
onChange?: (value: string) => void;
|
|
34
|
+
/** Compact mode - just shows color swatch */
|
|
35
|
+
compact?: boolean;
|
|
36
|
+
/** Show a transparent toggle button inside the input */
|
|
37
|
+
transparent?: boolean;
|
|
38
|
+
/** Whether transparent is currently active */
|
|
39
|
+
isTransparent?: () => boolean;
|
|
40
|
+
/** Called when transparent toggle changes */
|
|
41
|
+
onTransparentChange?: (value: boolean) => void;
|
|
42
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Show, createUniqueId, type Accessor, type JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
export type InputA11y = {
|
|
4
|
+
inputId: string;
|
|
5
|
+
descriptionId: string | undefined;
|
|
6
|
+
errorId: string;
|
|
7
|
+
ariaDescribedBy: Accessor<string | undefined>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Module-level fallback counter for `createUniqueId`.
|
|
12
|
+
*
|
|
13
|
+
* Solid's `createUniqueId` requires a hydrating render context. The current
|
|
14
|
+
* @valentinkolb/ssr release renders islands via `renderToString` (sync, NOT
|
|
15
|
+
* hydrating), which throws "getNextContextId cannot be used under
|
|
16
|
+
* non-hydrating context" when an island contains TextInput / similar inputs.
|
|
17
|
+
*
|
|
18
|
+
* Islands are re-mounted client-side anyway (no SSR-hydration matchup for
|
|
19
|
+
* the island content), so a stable-but-arbitrary id is fine for the SSR
|
|
20
|
+
* pass — client mount runs `createUniqueId` again with a real context.
|
|
21
|
+
*/
|
|
22
|
+
let fallbackIdCounter = 0;
|
|
23
|
+
const safeUniqueId = (): string => {
|
|
24
|
+
try {
|
|
25
|
+
return createUniqueId();
|
|
26
|
+
} catch {
|
|
27
|
+
fallbackIdCounter += 1;
|
|
28
|
+
return `ssr-${fallbackIdCounter}`;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const createInputA11y = (props: {
|
|
33
|
+
description?: string | JSX.Element;
|
|
34
|
+
error?: () => string | undefined;
|
|
35
|
+
inputId?: string;
|
|
36
|
+
}): InputA11y => {
|
|
37
|
+
const baseId = safeUniqueId();
|
|
38
|
+
const inputId = props.inputId ?? `input-${baseId}`;
|
|
39
|
+
const descriptionId = props.description ? `${inputId}-desc` : undefined;
|
|
40
|
+
const errorId = `${inputId}-error`;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
inputId,
|
|
44
|
+
descriptionId,
|
|
45
|
+
errorId,
|
|
46
|
+
ariaDescribedBy: () => {
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
if (descriptionId) parts.push(descriptionId);
|
|
49
|
+
if (props.error?.()) parts.push(errorId);
|
|
50
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Props for InputWrapper component
|
|
57
|
+
*/
|
|
58
|
+
export type InputWrapperProps = {
|
|
59
|
+
label?: string | JSX.Element;
|
|
60
|
+
description?: string | JSX.Element;
|
|
61
|
+
error?: string | undefined;
|
|
62
|
+
required?: boolean;
|
|
63
|
+
inputId: string;
|
|
64
|
+
descriptionId?: string;
|
|
65
|
+
errorId?: string;
|
|
66
|
+
children: JSX.Element;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shared wrapper for labeled inputs. Input IDs and aria wiring are created outside
|
|
71
|
+
* the wrapper so the input subtree stays structurally stable during reactive updates.
|
|
72
|
+
*/
|
|
73
|
+
export const InputWrapper = (props: InputWrapperProps) => {
|
|
74
|
+
return (
|
|
75
|
+
<div class="flex flex-col gap-1">
|
|
76
|
+
<Show when={props.label || props.description}>
|
|
77
|
+
<label for={props.inputId}>
|
|
78
|
+
<Show when={props.label}>
|
|
79
|
+
<p class="block text-sm font-medium">
|
|
80
|
+
{props.label}
|
|
81
|
+
<Show when={props.required}>
|
|
82
|
+
<span class="ml-0.5 text-red-500" aria-hidden="true">
|
|
83
|
+
*
|
|
84
|
+
</span>
|
|
85
|
+
</Show>
|
|
86
|
+
</p>
|
|
87
|
+
</Show>
|
|
88
|
+
<Show when={props.description}>
|
|
89
|
+
<p id={props.descriptionId} class="text-dimmed block text-xs">
|
|
90
|
+
{props.description}
|
|
91
|
+
</p>
|
|
92
|
+
</Show>
|
|
93
|
+
</label>
|
|
94
|
+
</Show>
|
|
95
|
+
|
|
96
|
+
{props.children}
|
|
97
|
+
|
|
98
|
+
<Show when={props.error}>
|
|
99
|
+
<p id={props.errorId} class="text-xs text-red-500" role="alert" aria-live="polite">
|
|
100
|
+
{props.error}
|
|
101
|
+
</p>
|
|
102
|
+
</Show>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type AvatarSize = "sm" | "md" | "lg" | "xl";
|
|
2
|
+
|
|
3
|
+
type AvatarProps = {
|
|
4
|
+
username: string;
|
|
5
|
+
size?: AvatarSize;
|
|
6
|
+
class?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const SIZE_CLASSES: Record<AvatarSize, string> = {
|
|
10
|
+
sm: "h-8 w-8 text-xs",
|
|
11
|
+
md: "h-10 w-10 text-sm",
|
|
12
|
+
lg: "h-16 w-16 text-lg",
|
|
13
|
+
xl: "h-20 w-20 text-xl",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Displays a user avatar with initials. */
|
|
17
|
+
export default function Avatar(props: AvatarProps) {
|
|
18
|
+
const sizeClass = SIZE_CLASSES[props.size ?? "md"];
|
|
19
|
+
const initials = props.username.slice(0, 2).toUpperCase();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
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 ${sizeClass} ${props.class ?? ""}`}
|
|
24
|
+
>
|
|
25
|
+
{initials}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
}
|