@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,196 @@
|
|
|
1
|
+
/* Navigation and list interaction utilities — flat, solid surfaces */
|
|
2
|
+
@utility list-item {
|
|
3
|
+
display: flex !important;
|
|
4
|
+
padding: var(--theme-list-padding);
|
|
5
|
+
border-left: var(--theme-list-inactive-border);
|
|
6
|
+
@apply rounded-lg items-center gap-2 text-sm leading-none cursor-pointer text-dimmed;
|
|
7
|
+
@apply transition-[background-color,color] duration-150 ease-out hover:text-secondary;
|
|
8
|
+
|
|
9
|
+
& + .list-item {
|
|
10
|
+
margin-top: 0.25rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
&:hover {
|
|
14
|
+
background: var(--theme-list-hover-bg);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* Sidebar layout primitives */
|
|
19
|
+
@utility sidebar {
|
|
20
|
+
@apply flex h-full min-h-0 flex-col gap-3 rounded-lg border border-zinc-200 bg-white;
|
|
21
|
+
@apply dark:border-zinc-800 dark:bg-zinc-900;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@utility sidebar-container {
|
|
25
|
+
@apply hidden lg:flex w-52 shrink-0 min-h-0 overflow-y-auto flex-col gap-3;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@utility sidebar-header-icon {
|
|
29
|
+
@apply w-6 h-6 rounded flex items-center justify-center text-white shrink-0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@utility sidebar-header-text {
|
|
33
|
+
@apply min-w-0 flex-1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@utility sidebar-header-title {
|
|
37
|
+
@apply truncate text-sm font-semibold text-primary;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@utility sidebar-header-subtitle {
|
|
41
|
+
@apply text-xs text-dimmed truncate;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@utility sidebar-header-settings {
|
|
45
|
+
@apply p-0.5 text-dimmed hover:text-primary transition-colors shrink-0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@utility sidebar-group {
|
|
49
|
+
@apply flex flex-col gap-1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@utility sidebar-container-mobile {
|
|
53
|
+
@apply lg:hidden flex flex-col gap-3;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@utility sidebar-mobile-toggle {
|
|
57
|
+
@apply sidebar-header cursor-pointer select-none list-none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@utility sidebar-mobile-actions {
|
|
61
|
+
@apply flex flex-wrap gap-2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@utility sidebar-item-mobile {
|
|
65
|
+
@apply btn-input btn-input-sm;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@utility sidebar-header {
|
|
69
|
+
@apply flex min-h-10 items-center gap-2;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@utility sidebar-body {
|
|
73
|
+
@apply flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@utility sidebar-footer {
|
|
77
|
+
@apply mt-auto flex flex-col gap-2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@utility sidebar-section {
|
|
81
|
+
@apply flex flex-col gap-1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@utility sidebar-section-title {
|
|
85
|
+
@apply text-[0.6875rem] font-medium normal-case tracking-normal text-dimmed select-none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@utility sidebar-controls {
|
|
89
|
+
@apply flex flex-col gap-2;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@utility sidebar-control-row {
|
|
93
|
+
@apply flex items-center gap-2;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Sidebar item primitives */
|
|
97
|
+
@utility sidebar-item {
|
|
98
|
+
display: flex !important;
|
|
99
|
+
@apply min-h-8 items-center gap-2 rounded-lg px-2 py-1.5 text-xs leading-none text-dimmed;
|
|
100
|
+
@apply transition-[background-color,color] duration-150 ease-out hover:text-secondary;
|
|
101
|
+
|
|
102
|
+
&:hover {
|
|
103
|
+
background: var(--theme-list-hover-bg);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@utility sidebar-item-tall {
|
|
108
|
+
@apply min-h-10 py-2;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@utility sidebar-item-active {
|
|
112
|
+
@apply bg-blue-50 text-blue-700 font-medium;
|
|
113
|
+
@apply hover:bg-blue-100 hover:text-blue-700;
|
|
114
|
+
@apply dark:bg-blue-950 dark:text-blue-300;
|
|
115
|
+
@apply dark:hover:bg-blue-900 dark:hover:text-blue-200;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@utility sidebar-item-meta {
|
|
119
|
+
@apply mt-0.5 text-left text-xs leading-tight text-dimmed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@utility sidebar-item-action {
|
|
123
|
+
@apply ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-dimmed;
|
|
124
|
+
@apply transition-colors duration-150 ease-out hover:text-secondary hover:bg-zinc-100;
|
|
125
|
+
@apply dark:hover:bg-zinc-800;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Sidebar tree primitives */
|
|
129
|
+
@utility sidebar-tree {
|
|
130
|
+
@apply flex flex-col gap-0.5;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@utility sidebar-tree-item {
|
|
134
|
+
@apply flex flex-col gap-0.5;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@utility sidebar-tree-row {
|
|
138
|
+
display: flex !important;
|
|
139
|
+
padding-left: calc(var(--sidebar-level, 0) * 0.75rem + 0.625rem);
|
|
140
|
+
@apply min-h-8 items-center gap-2 rounded-lg py-1 pr-2 text-xs text-dimmed;
|
|
141
|
+
@apply transition-[background-color,color] duration-150 ease-out hover:text-secondary;
|
|
142
|
+
|
|
143
|
+
&:hover {
|
|
144
|
+
background: var(--theme-list-hover-bg);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@utility sidebar-tree-toggle {
|
|
149
|
+
@apply inline-flex h-4 w-4 shrink-0 items-center justify-center text-[10px] text-dimmed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@utility sidebar-tree-children {
|
|
153
|
+
@apply flex flex-col gap-0.5;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@utility list-item-active {
|
|
157
|
+
border-left: var(--theme-list-active-border);
|
|
158
|
+
@apply rounded-lg bg-blue-50 text-blue-700 font-medium;
|
|
159
|
+
@apply hover:bg-blue-100 hover:text-blue-700;
|
|
160
|
+
@apply dark:bg-blue-950 dark:text-blue-300;
|
|
161
|
+
@apply dark:hover:bg-blue-900 dark:hover:text-blue-200;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@utility section-label {
|
|
165
|
+
@apply text-[0.6875rem] font-medium normal-case tracking-normal mb-5 text-dimmed select-none;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@utility rail-item {
|
|
169
|
+
border-right: var(--theme-rail-item-inactive-border-r);
|
|
170
|
+
@apply w-8 h-8 rounded-lg;
|
|
171
|
+
@apply flex items-center justify-center text-dimmed;
|
|
172
|
+
@apply transition-[background-color,color] duration-150 ease-out;
|
|
173
|
+
|
|
174
|
+
&:hover {
|
|
175
|
+
background: var(--theme-list-hover-bg);
|
|
176
|
+
@apply text-secondary;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@utility rail-item-active {
|
|
181
|
+
border-right: var(--theme-rail-item-active-border-r);
|
|
182
|
+
@apply rounded-lg text-blue-600 dark:text-blue-300;
|
|
183
|
+
@apply bg-blue-50 dark:bg-blue-950;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@utility bottom-bar-item {
|
|
187
|
+
@apply flex flex-col items-center gap-0.5 text-[10px] py-1.5 px-3 transition-colors text-dimmed;
|
|
188
|
+
|
|
189
|
+
&:hover {
|
|
190
|
+
@apply text-secondary;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@utility bottom-bar-item-active {
|
|
195
|
+
@apply text-primary font-medium;
|
|
196
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Cross-app ambient module declarations. Every app's tsconfig includes this
|
|
2
|
+
// so apps can import non-TS assets (e.g. `import md from './x.md' with { type: "text" }`)
|
|
3
|
+
// without re-declaring the stub per-package.
|
|
4
|
+
|
|
5
|
+
declare module "*.md" {
|
|
6
|
+
const content: string;
|
|
7
|
+
export default content;
|
|
8
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Show, type JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared building blocks for admin "Settings" forms — extracted from
|
|
5
|
+
* FilesSettingsForm / WeatherSettingsForm / CoreSettingsForm which all
|
|
6
|
+
* implement the same change-tracking + sticky-save-bar UX.
|
|
7
|
+
*
|
|
8
|
+
* NOT an island. Subcomponents are pulled into the consuming `*.island.tsx`
|
|
9
|
+
* file at compile time, so the island boundary stays at the form level (the
|
|
10
|
+
* SSR plugin discovers islands by `.island.tsx` suffix).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stable JSON-based equality check. Used to detect "did this setting value
|
|
17
|
+
* change vs. its initial state?" — works for primitives, arrays, plain objects.
|
|
18
|
+
* Order-sensitive for arrays/object keys; that's intentional for settings
|
|
19
|
+
* where order matters (string_list, number_list).
|
|
20
|
+
*/
|
|
21
|
+
export const sameSettingValue = (a: unknown, b: unknown): boolean =>
|
|
22
|
+
JSON.stringify(a) === JSON.stringify(b);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parses a settings-API error response into a user-message + per-field error
|
|
26
|
+
* map. Mirrors the shape every settings PUT endpoint returns.
|
|
27
|
+
*/
|
|
28
|
+
export const readSettingsError = async (
|
|
29
|
+
response: Response,
|
|
30
|
+
fallback: string,
|
|
31
|
+
): Promise<{ message: string; fields: Record<string, string> }> => {
|
|
32
|
+
const data = (await response.json().catch(() => null)) as
|
|
33
|
+
| { message?: string; errors?: Record<string, string> }
|
|
34
|
+
| null;
|
|
35
|
+
return {
|
|
36
|
+
message: data?.message ?? fallback,
|
|
37
|
+
fields: data?.errors ?? {},
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ── Field row ────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export type SettingsFieldProps = {
|
|
44
|
+
label: string;
|
|
45
|
+
description: string;
|
|
46
|
+
/** Reactive accessor for the per-field error string (undefined = no error). */
|
|
47
|
+
error: () => string | undefined;
|
|
48
|
+
/** Reactive accessor for "is this field's value different from its initial?". */
|
|
49
|
+
changed?: () => boolean;
|
|
50
|
+
/** The actual input control (TextInput, NumberInput, Switch, etc.). */
|
|
51
|
+
children: JSX.Element;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Single-row field wrapper for settings forms.
|
|
56
|
+
*
|
|
57
|
+
* Renders label + description on top, the input below, an inline error message
|
|
58
|
+
* underneath, and a soft amber background + a small dot when the value has
|
|
59
|
+
* unsaved changes. Mirrors the per-row UX of `/admin/settings`.
|
|
60
|
+
*
|
|
61
|
+
* Uses a `<div>` (not a `<label>`) — the actual `<input>` inside `children`
|
|
62
|
+
* has its own a11y attributes; this is purely a visual heading.
|
|
63
|
+
*/
|
|
64
|
+
export function SettingsField(props: SettingsFieldProps) {
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
class="flex flex-col gap-1.5 px-3 py-3"
|
|
68
|
+
classList={{ "bg-amber-50/50 dark:bg-amber-950/20": props.changed?.() ?? false }}
|
|
69
|
+
>
|
|
70
|
+
<div class="flex flex-col gap-0.5">
|
|
71
|
+
<div class="flex items-center gap-2">
|
|
72
|
+
<div class="text-sm font-medium text-primary">{props.label}</div>
|
|
73
|
+
<Show when={props.changed?.()}>
|
|
74
|
+
<span
|
|
75
|
+
class="inline-block w-1.5 h-1.5 rounded-full bg-amber-500"
|
|
76
|
+
title="Unsaved change"
|
|
77
|
+
/>
|
|
78
|
+
</Show>
|
|
79
|
+
</div>
|
|
80
|
+
<p class="text-xs text-dimmed">{props.description}</p>
|
|
81
|
+
</div>
|
|
82
|
+
{props.children}
|
|
83
|
+
<Show when={props.error()}>
|
|
84
|
+
<p class="text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
|
|
85
|
+
<i class="ti ti-alert-circle text-xs" /> {props.error()}
|
|
86
|
+
</p>
|
|
87
|
+
</Show>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Sticky save bar ─────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export type SettingsSaveBarProps = {
|
|
95
|
+
/** Number of unsaved changes — when 0 the bar hides. */
|
|
96
|
+
changeCount: () => number;
|
|
97
|
+
/** Mutation loading flag — disables both buttons + flips Save → Saving. */
|
|
98
|
+
loading: () => boolean;
|
|
99
|
+
/** Reset all unsaved changes back to the initial values. */
|
|
100
|
+
onDiscard: () => void;
|
|
101
|
+
/** Trigger the bulk PUT save. */
|
|
102
|
+
onSave: () => void;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sticky bottom bar shown when at least one field has unsaved changes.
|
|
107
|
+
* Displays "<n> unsaved change(s)" + Discard + Save buttons. Identical UX
|
|
108
|
+
* across files/weather/core settings forms.
|
|
109
|
+
*/
|
|
110
|
+
export function SettingsSaveBar(props: SettingsSaveBarProps) {
|
|
111
|
+
return (
|
|
112
|
+
<Show when={props.changeCount() > 0}>
|
|
113
|
+
<div class="sticky bottom-0 z-10 border-t border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 flex items-center justify-between gap-3">
|
|
114
|
+
<p class="text-xs text-dimmed">
|
|
115
|
+
<span class="font-medium text-primary">{props.changeCount()}</span>
|
|
116
|
+
{" "}unsaved change{props.changeCount() > 1 ? "s" : ""}
|
|
117
|
+
</p>
|
|
118
|
+
<div class="flex items-center gap-2">
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
class="btn-secondary btn-sm"
|
|
122
|
+
onClick={props.onDiscard}
|
|
123
|
+
disabled={props.loading()}
|
|
124
|
+
>
|
|
125
|
+
Discard
|
|
126
|
+
</button>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
class="btn-primary btn-sm"
|
|
130
|
+
onClick={props.onSave}
|
|
131
|
+
disabled={props.loading()}
|
|
132
|
+
>
|
|
133
|
+
<Show
|
|
134
|
+
when={props.loading()}
|
|
135
|
+
fallback={
|
|
136
|
+
<>
|
|
137
|
+
<i class="ti ti-device-floppy text-xs" /> Save all
|
|
138
|
+
</>
|
|
139
|
+
}
|
|
140
|
+
>
|
|
141
|
+
<i class="ti ti-loader-2 animate-spin text-xs" /> Saving...
|
|
142
|
+
</Show>
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</Show>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { JSX } from "solid-js";
|
|
2
|
+
import { render } from "solid-js/web";
|
|
3
|
+
|
|
4
|
+
export type DialogClose<T> = (result?: T) => void;
|
|
5
|
+
|
|
6
|
+
export type OpenDialogOptions = {
|
|
7
|
+
panelClassName?: string;
|
|
8
|
+
contentClassName?: string;
|
|
9
|
+
initialFocus?: "first-input" | "none" | ((dialog: HTMLDialogElement) => HTMLElement | null);
|
|
10
|
+
cancelBehavior?: "resolve-undefined" | "ignore";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type DialogRender<T> = (
|
|
14
|
+
close: DialogClose<T>,
|
|
15
|
+
ctx: {
|
|
16
|
+
dialog: HTMLDialogElement;
|
|
17
|
+
},
|
|
18
|
+
) => JSX.Element;
|
|
19
|
+
|
|
20
|
+
export type DialogCore = {
|
|
21
|
+
open: <T>(view: DialogRender<T>, options?: OpenDialogOptions) => Promise<T | undefined>;
|
|
22
|
+
close: (result?: unknown) => void;
|
|
23
|
+
isOpen: () => boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type DialogState = {
|
|
27
|
+
element?: HTMLDialogElement;
|
|
28
|
+
dispose?: () => void;
|
|
29
|
+
resolve?: (value: unknown) => void;
|
|
30
|
+
scrollLocked?: boolean;
|
|
31
|
+
previousBodyOverflow?: string;
|
|
32
|
+
previousHtmlOverflow?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const DEFAULT_PANEL_CLASS = "dialog-panel";
|
|
36
|
+
const DEFAULT_CONTENT_CLASS = "text-base text-zinc-800 dark:text-zinc-200";
|
|
37
|
+
|
|
38
|
+
const resolveInitialFocusTarget = (dialog: HTMLDialogElement, initialFocus: OpenDialogOptions["initialFocus"]) => {
|
|
39
|
+
if (initialFocus === "none") return null;
|
|
40
|
+
if (typeof initialFocus === "function") return initialFocus(dialog);
|
|
41
|
+
return dialog.querySelector<HTMLElement>("input:not([type='hidden']), textarea, select, button");
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const applyCancelBehavior = <T>(dialog: HTMLDialogElement, close: DialogClose<T>, behavior: OpenDialogOptions["cancelBehavior"]) => {
|
|
45
|
+
dialog.oncancel = (event) => {
|
|
46
|
+
if (behavior === "ignore") {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
close(undefined);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
dialog.onclick = (event) => {
|
|
55
|
+
if (event.target !== dialog) return;
|
|
56
|
+
if (behavior === "ignore") return;
|
|
57
|
+
close(undefined);
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const createDialogCore = (): DialogCore => {
|
|
62
|
+
const state: DialogState = {};
|
|
63
|
+
|
|
64
|
+
const ensureDialogElement = () => {
|
|
65
|
+
if (typeof document === "undefined") throw new Error("Dialog core is browser-only");
|
|
66
|
+
if (state.element && document.body.contains(state.element)) return state.element;
|
|
67
|
+
|
|
68
|
+
const element = document.createElement("dialog");
|
|
69
|
+
document.body.appendChild(element);
|
|
70
|
+
state.element = element;
|
|
71
|
+
return element;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const clearRenderedContent = () => {
|
|
75
|
+
state.dispose?.();
|
|
76
|
+
state.dispose = undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const lockPageScroll = () => {
|
|
80
|
+
if (typeof document === "undefined" || state.scrollLocked) return;
|
|
81
|
+
|
|
82
|
+
state.previousBodyOverflow = document.body.style.overflow;
|
|
83
|
+
state.previousHtmlOverflow = document.documentElement.style.overflow;
|
|
84
|
+
document.body.style.overflow = "hidden";
|
|
85
|
+
document.documentElement.style.overflow = "hidden";
|
|
86
|
+
state.scrollLocked = true;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const unlockPageScroll = () => {
|
|
90
|
+
if (typeof document === "undefined" || !state.scrollLocked) return;
|
|
91
|
+
|
|
92
|
+
document.body.style.overflow = state.previousBodyOverflow ?? "";
|
|
93
|
+
document.documentElement.style.overflow = state.previousHtmlOverflow ?? "";
|
|
94
|
+
state.scrollLocked = false;
|
|
95
|
+
state.previousBodyOverflow = undefined;
|
|
96
|
+
state.previousHtmlOverflow = undefined;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const close: DialogCore["close"] = (result) => {
|
|
100
|
+
const dialog = state.element;
|
|
101
|
+
if (!dialog) return;
|
|
102
|
+
|
|
103
|
+
clearRenderedContent();
|
|
104
|
+
if (dialog.open) dialog.close();
|
|
105
|
+
unlockPageScroll();
|
|
106
|
+
|
|
107
|
+
const resolve = state.resolve;
|
|
108
|
+
state.resolve = undefined;
|
|
109
|
+
resolve?.(result);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const open = <T>(view: DialogRender<T>, options: OpenDialogOptions = {}): Promise<T | undefined> => {
|
|
113
|
+
const dialog = ensureDialogElement();
|
|
114
|
+
if (dialog.open) close(undefined);
|
|
115
|
+
|
|
116
|
+
dialog.className = options.panelClassName ?? DEFAULT_PANEL_CLASS;
|
|
117
|
+
dialog.innerHTML = "";
|
|
118
|
+
|
|
119
|
+
const content = document.createElement("div");
|
|
120
|
+
content.className = options.contentClassName ?? DEFAULT_CONTENT_CLASS;
|
|
121
|
+
dialog.appendChild(content);
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
state.resolve = (value) => resolve(value as T | undefined);
|
|
125
|
+
|
|
126
|
+
const closeTyped: DialogClose<T> = (result) => close(result);
|
|
127
|
+
state.dispose = render(() => view(closeTyped, { dialog }), content);
|
|
128
|
+
|
|
129
|
+
applyCancelBehavior(dialog, closeTyped, options.cancelBehavior ?? "resolve-undefined");
|
|
130
|
+
dialog.showModal();
|
|
131
|
+
lockPageScroll();
|
|
132
|
+
|
|
133
|
+
requestAnimationFrame(() => {
|
|
134
|
+
resolveInitialFocusTarget(dialog, options.initialFocus ?? "first-input")?.focus();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
open,
|
|
141
|
+
close,
|
|
142
|
+
isOpen: () => !!state.element?.open,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const dialogCore = createDialogCore();
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createSignal, createEffect, Show, createMemo } from "solid-js";
|
|
2
|
+
import Dropdown from "../misc/Dropdown";
|
|
3
|
+
import type { DropdownItem } from "../misc/Dropdown";
|
|
4
|
+
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
export type FilterChipOption = {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
color?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type FilterChipSection = {
|
|
17
|
+
label?: string;
|
|
18
|
+
options: FilterChipOption[];
|
|
19
|
+
/** Allow multiple selections in this section (default: false) */
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type FilterChipProps = {
|
|
24
|
+
/** Chip label */
|
|
25
|
+
label: string;
|
|
26
|
+
/** Chip icon */
|
|
27
|
+
icon: string;
|
|
28
|
+
/** Options as sections (use single section for flat list) */
|
|
29
|
+
options: FilterChipSection[];
|
|
30
|
+
/** Selected values (controlled) */
|
|
31
|
+
value: string[];
|
|
32
|
+
/** Called on change */
|
|
33
|
+
onChange: (value: string[]) => void;
|
|
34
|
+
/** Override active styling (default: value.length > 0) */
|
|
35
|
+
isActive?: boolean;
|
|
36
|
+
/** Dropdown position (default: "bottom-right") */
|
|
37
|
+
position?: "bottom-left" | "bottom-right";
|
|
38
|
+
/**
|
|
39
|
+
* Default values for reset (instead of clear).
|
|
40
|
+
* If provided, shows "Reset" instead of "Clear" and hides when at default.
|
|
41
|
+
* Also hides the count in the trigger.
|
|
42
|
+
*/
|
|
43
|
+
defaultValue?: string[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Component
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Filter chip using the shared Dropdown component.
|
|
52
|
+
* Each section can be single-select or multi-select independently.
|
|
53
|
+
* Changes are applied when the dropdown closes.
|
|
54
|
+
*/
|
|
55
|
+
export default function FilterChip(props: FilterChipProps) {
|
|
56
|
+
// Local selection state (tracks pending changes)
|
|
57
|
+
const [localValue, setLocalValue] = createSignal<string[]>([...props.value]);
|
|
58
|
+
|
|
59
|
+
// Sync local state when props change
|
|
60
|
+
createEffect(() => setLocalValue([...props.value]));
|
|
61
|
+
|
|
62
|
+
// Computed values
|
|
63
|
+
const hasChanges = createMemo(() => {
|
|
64
|
+
const local = localValue();
|
|
65
|
+
const original = props.value;
|
|
66
|
+
return local.length !== original.length || local.some((v) => !original.includes(v));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const isActive = () => props.isActive ?? localValue().length > 0;
|
|
70
|
+
const isSelected = (value: string) => localValue().includes(value);
|
|
71
|
+
const selectedCount = () => localValue().length;
|
|
72
|
+
const hasDefaultValue = () => (props.defaultValue?.length ?? 0) > 0;
|
|
73
|
+
|
|
74
|
+
const isAtDefault = () => {
|
|
75
|
+
const def = props.defaultValue;
|
|
76
|
+
if (!def) return false;
|
|
77
|
+
const local = localValue();
|
|
78
|
+
return local.length === def.length && local.every((v) => def.includes(v));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Find which section a value belongs to
|
|
82
|
+
const getSectionForValue = (value: string) => props.options.findIndex((s) => s.options.some((o) => o.value === value));
|
|
83
|
+
|
|
84
|
+
// Toggle option selection
|
|
85
|
+
const toggleOption = (value: string) => {
|
|
86
|
+
const sectionIndex = getSectionForValue(value);
|
|
87
|
+
const section = props.options[sectionIndex];
|
|
88
|
+
if (!section) return;
|
|
89
|
+
|
|
90
|
+
const isMultiple = section.multiple ?? false;
|
|
91
|
+
|
|
92
|
+
setLocalValue((prev) => {
|
|
93
|
+
const isCurrentlySelected = prev.includes(value);
|
|
94
|
+
|
|
95
|
+
if (isMultiple) {
|
|
96
|
+
return isCurrentlySelected ? prev.filter((v) => v !== value) : [...prev, value];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Single-select: replace any value from this section
|
|
100
|
+
const sectionValues = new Set(section.options.map((o) => o.value));
|
|
101
|
+
const otherValues = prev.filter((v) => !sectionValues.has(v));
|
|
102
|
+
return isCurrentlySelected ? otherValues : [...otherValues, value];
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const clearOrReset = () => setLocalValue(props.defaultValue ? [...props.defaultValue] : []);
|
|
107
|
+
|
|
108
|
+
const handleClose = () => {
|
|
109
|
+
if (hasChanges()) props.onChange(localValue());
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Build dropdown elements
|
|
113
|
+
const dropdownElements = (): DropdownItem[] => {
|
|
114
|
+
const elements: DropdownItem[] = [];
|
|
115
|
+
|
|
116
|
+
for (const section of props.options) {
|
|
117
|
+
const isMultiple = section.multiple ?? false;
|
|
118
|
+
|
|
119
|
+
const sectionItems = section.options.map((option) => ({
|
|
120
|
+
element: (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
class="flex w-full items-center gap-3 px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 transition-colors hover:bg-white/30 dark:hover:bg-white/10"
|
|
124
|
+
onClick={(e) => {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
toggleOption(option.value);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<Show when={isMultiple}>
|
|
131
|
+
<input type="checkbox" checked={isSelected(option.value)} readOnly aria-hidden="true" tabindex={-1} class="shrink-0 pointer-events-none" />
|
|
132
|
+
</Show>
|
|
133
|
+
|
|
134
|
+
<Show when={option.icon && !isMultiple}>
|
|
135
|
+
<i class={`${option.icon} ${isSelected(option.value) ? "text-blue-500" : "text-zinc-400"}`} />
|
|
136
|
+
</Show>
|
|
137
|
+
|
|
138
|
+
<Show when={option.color}>
|
|
139
|
+
<div class="w-3 h-3 rounded-full shrink-0" style={`background-color: ${option.color}`} />
|
|
140
|
+
</Show>
|
|
141
|
+
|
|
142
|
+
<span class="flex-1 truncate text-left">{option.label}</span>
|
|
143
|
+
|
|
144
|
+
<Show when={!isMultiple && isSelected(option.value)}>
|
|
145
|
+
<i class="ti ti-check text-blue-500" />
|
|
146
|
+
</Show>
|
|
147
|
+
</button>
|
|
148
|
+
),
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
if (section.label) {
|
|
152
|
+
elements.push({ sectionLabel: section.label, items: sectionItems });
|
|
153
|
+
} else {
|
|
154
|
+
elements.push(...sectionItems);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Add clear/reset button when applicable
|
|
159
|
+
const shouldShowButton = hasDefaultValue() ? !isAtDefault() : selectedCount() > 0;
|
|
160
|
+
if (shouldShowButton) {
|
|
161
|
+
elements.push({
|
|
162
|
+
items: [
|
|
163
|
+
{
|
|
164
|
+
icon: hasDefaultValue() ? "ti ti-refresh" : "ti ti-x",
|
|
165
|
+
label: hasDefaultValue() ? "Reset" : "Clear",
|
|
166
|
+
variant: "danger" as const,
|
|
167
|
+
action: clearOrReset,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return elements;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const trigger = (
|
|
177
|
+
<div class={`btn-input btn-input-sm ${isActive() ? "btn-input-active" : ""}`}>
|
|
178
|
+
<i class={`${props.icon} ${isActive() ? "text-blue-600 dark:text-blue-300" : "text-zinc-500 dark:text-zinc-400"}`} />
|
|
179
|
+
<span class={isActive() ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-700 dark:text-zinc-300"}>
|
|
180
|
+
{props.label}
|
|
181
|
+
<Show when={!hasDefaultValue() && selectedCount() > 0}>{` (${selectedCount()})`}</Show>
|
|
182
|
+
</span>
|
|
183
|
+
<i class="ti ti-chevron-down text-zinc-400 text-[10px]" />
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<Dropdown
|
|
189
|
+
trigger={trigger}
|
|
190
|
+
elements={dropdownElements()}
|
|
191
|
+
position={props.position ?? "bottom-left"}
|
|
192
|
+
onClose={handleClose}
|
|
193
|
+
width="w-52"
|
|
194
|
+
/>
|
|
195
|
+
);
|
|
196
|
+
}
|