@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,389 @@
|
|
|
1
|
+
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";
|
|
2
|
+
import { dialogCore } from "../ui";
|
|
3
|
+
import { mutation, timed } from "@valentinkolb/stdlib/solid";
|
|
4
|
+
import { openGlobalSearchHelpDialog, type GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
5
|
+
|
|
6
|
+
type SearchMetadata = {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type SearchItem = {
|
|
12
|
+
appId: string;
|
|
13
|
+
appName: string;
|
|
14
|
+
appIcon: string;
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
href: string;
|
|
18
|
+
preview?: string;
|
|
19
|
+
icon?: string;
|
|
20
|
+
priority?: number;
|
|
21
|
+
metadata?: SearchMetadata[];
|
|
22
|
+
previewUrl?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type SearchResponse = {
|
|
26
|
+
query: string;
|
|
27
|
+
count: number;
|
|
28
|
+
items: SearchItem[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ParsedInput = {
|
|
32
|
+
query: string;
|
|
33
|
+
tags: string[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type GlobalSearchDialogProps = {
|
|
37
|
+
close: () => void;
|
|
38
|
+
helpApps: GlobalSearchHelpApp[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const PROVIDER_LIMIT = 10;
|
|
42
|
+
const MIN_QUERY_LENGTH = 2;
|
|
43
|
+
const SEARCH_DEBOUNCE_MS = 200;
|
|
44
|
+
const TAG_TOKEN_PATTERN = /^[^\s#]+$/;
|
|
45
|
+
|
|
46
|
+
const rowKey = (item: SearchItem) => `row:${item.appId}:${item.id}`;
|
|
47
|
+
const isValidImagePreviewUrl = (url?: string) => typeof url === "string" && url.startsWith("/");
|
|
48
|
+
const sortByPriorityAndTitle = (a: SearchItem, b: SearchItem) => {
|
|
49
|
+
const byPriority = (b.priority ?? 0) - (a.priority ?? 0);
|
|
50
|
+
if (byPriority !== 0) return byPriority;
|
|
51
|
+
return a.title.localeCompare(b.title);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const parseInput = (raw: string): ParsedInput => {
|
|
55
|
+
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
|
56
|
+
const tags: string[] = [];
|
|
57
|
+
const queryTokens: string[] = [];
|
|
58
|
+
|
|
59
|
+
for (const token of tokens) {
|
|
60
|
+
if (token.startsWith("#") && token.length > 1) {
|
|
61
|
+
const tag = token.slice(1).toLowerCase();
|
|
62
|
+
if (TAG_TOKEN_PATTERN.test(tag)) {
|
|
63
|
+
tags.push(tag);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
queryTokens.push(token);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
query: queryTokens.join(" "),
|
|
73
|
+
tags: [...new Set(tags)],
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const metadataRows = (metadata?: SearchMetadata[]) =>
|
|
78
|
+
(metadata ?? [])
|
|
79
|
+
.filter((entry) => entry.label.trim().length > 0 && entry.value.trim().length > 0)
|
|
80
|
+
.slice(0, 5);
|
|
81
|
+
|
|
82
|
+
export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
83
|
+
const [rawInput, setRawInput] = createSignal("");
|
|
84
|
+
const [resultItems, setResultItems] = createSignal<SearchItem[]>([]);
|
|
85
|
+
const [activeIndex, setActiveIndex] = createSignal(0);
|
|
86
|
+
const [previewFailed, setPreviewFailed] = createSignal(false);
|
|
87
|
+
const [requestError, setRequestError] = createSignal<string | null>(null);
|
|
88
|
+
|
|
89
|
+
let inputRef: HTMLInputElement | undefined;
|
|
90
|
+
const rowRefs = new Map<string, HTMLButtonElement>();
|
|
91
|
+
|
|
92
|
+
const parsedInput = createMemo(() => parseInput(rawInput()));
|
|
93
|
+
const canSearch = createMemo(
|
|
94
|
+
() => parsedInput().tags.length > 0 || parsedInput().query.length >= MIN_QUERY_LENGTH,
|
|
95
|
+
);
|
|
96
|
+
const shouldShowList = createMemo(() => canSearch());
|
|
97
|
+
|
|
98
|
+
const searchMutation = mutation.create<SearchResponse, ParsedInput>({
|
|
99
|
+
mutation: async (input, ctx) => {
|
|
100
|
+
const params = new URLSearchParams({ provider_limit: String(PROVIDER_LIMIT) });
|
|
101
|
+
if (input.query.length > 0) params.set("q", input.query);
|
|
102
|
+
for (const tag of input.tags) params.append("tag", tag);
|
|
103
|
+
|
|
104
|
+
const response = await fetch(`/api/search?${params.toString()}`, {
|
|
105
|
+
signal: ctx.abortSignal,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const payload = (await response.json()) as SearchResponse | { message?: string };
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new Error("message" in payload ? payload.message ?? "Search failed." : "Search failed.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return payload as SearchResponse;
|
|
114
|
+
},
|
|
115
|
+
onSuccess: (payload) => {
|
|
116
|
+
setResultItems((payload.items ?? []).slice().sort(sortByPriorityAndTitle));
|
|
117
|
+
setActiveIndex(0);
|
|
118
|
+
setRequestError(null);
|
|
119
|
+
},
|
|
120
|
+
onError: (error) => {
|
|
121
|
+
if (error.name === "AbortError") return;
|
|
122
|
+
setResultItems([]);
|
|
123
|
+
setActiveIndex(0);
|
|
124
|
+
setRequestError(error.message || "Search failed.");
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const activeItem = createMemo(() => resultItems()[activeIndex()] ?? null);
|
|
129
|
+
|
|
130
|
+
const { debouncedFn: debounceSearch, cancel: cancelDebounce } = timed.debounce((input: ParsedInput) => {
|
|
131
|
+
setResultItems([]);
|
|
132
|
+
setActiveIndex(0);
|
|
133
|
+
searchMutation.abort();
|
|
134
|
+
void searchMutation.mutate(input);
|
|
135
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
136
|
+
|
|
137
|
+
const bindRowRef = (key: string, element?: HTMLButtonElement) => {
|
|
138
|
+
if (!element) {
|
|
139
|
+
rowRefs.delete(key);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
rowRefs.set(key, element);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const openHelp = () => {
|
|
146
|
+
props.close();
|
|
147
|
+
queueMicrotask(() => {
|
|
148
|
+
openGlobalSearchHelpDialog(props.helpApps);
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const openItem = (row?: SearchItem) => {
|
|
153
|
+
if (!row) return;
|
|
154
|
+
props.close();
|
|
155
|
+
window.location.href = row.href;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const moveSelection = (delta: -1 | 1) => {
|
|
159
|
+
const list = resultItems();
|
|
160
|
+
if (list.length === 0) return;
|
|
161
|
+
const next = (activeIndex() + delta + list.length) % list.length;
|
|
162
|
+
setActiveIndex(next);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
166
|
+
if (event.key === "ArrowDown") {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
moveSelection(1);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (event.key === "ArrowUp") {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
moveSelection(-1);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (event.key === "Enter") {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
openItem(activeItem() ?? undefined);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
createEffect(() => {
|
|
185
|
+
const maxIndex = resultItems().length - 1;
|
|
186
|
+
if (maxIndex < 0) {
|
|
187
|
+
setActiveIndex(0);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (activeIndex() > maxIndex) setActiveIndex(maxIndex);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
createEffect(() => {
|
|
195
|
+
const item = activeItem();
|
|
196
|
+
const key = item ? rowKey(item) : null;
|
|
197
|
+
|
|
198
|
+
setPreviewFailed(false);
|
|
199
|
+
if (!key) return;
|
|
200
|
+
|
|
201
|
+
queueMicrotask(() => {
|
|
202
|
+
rowRefs.get(key)?.scrollIntoView({ block: "nearest" });
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
createEffect(() => {
|
|
207
|
+
const input = parsedInput();
|
|
208
|
+
|
|
209
|
+
setRequestError(null);
|
|
210
|
+
if (!canSearch()) {
|
|
211
|
+
cancelDebounce();
|
|
212
|
+
searchMutation.abort();
|
|
213
|
+
setResultItems([]);
|
|
214
|
+
setActiveIndex(0);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
debounceSearch(input);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
onCleanup(() => {
|
|
222
|
+
cancelDebounce();
|
|
223
|
+
searchMutation.abort();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
onMount(() => {
|
|
227
|
+
requestAnimationFrame(() => inputRef?.focus());
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
class="flex h-full min-h-0 flex-col text-zinc-900 dark:text-zinc-100 [--spotlight-body-max:calc(50vh-5.5rem)] [@media(min-height:1100px)]:[--spotlight-body-max:calc(33vh-5.5rem)]"
|
|
233
|
+
onWheel={(event) => event.stopPropagation()}
|
|
234
|
+
>
|
|
235
|
+
<label class="flex items-center gap-3 px-4 py-3.5">
|
|
236
|
+
<i class="ti ti-search text-xl text-dimmed" />
|
|
237
|
+
<input
|
|
238
|
+
id="spotlight-input"
|
|
239
|
+
ref={inputRef}
|
|
240
|
+
type="search"
|
|
241
|
+
value={rawInput()}
|
|
242
|
+
onInput={(event) => setRawInput(event.currentTarget.value)}
|
|
243
|
+
onKeyDown={handleKeyDown}
|
|
244
|
+
placeholder="Search across apps..."
|
|
245
|
+
aria-label="Global search"
|
|
246
|
+
class="w-full border-0 bg-transparent text-base outline-none placeholder:text-dimmed md:text-lg"
|
|
247
|
+
spellcheck={false}
|
|
248
|
+
autocapitalize="off"
|
|
249
|
+
autocomplete="off"
|
|
250
|
+
autocorrect="off"
|
|
251
|
+
/>
|
|
252
|
+
<Show when={searchMutation.loading()}>
|
|
253
|
+
<i class="ti ti-loader-2 animate-spin text-dimmed" />
|
|
254
|
+
</Show>
|
|
255
|
+
</label>
|
|
256
|
+
|
|
257
|
+
<div
|
|
258
|
+
class="overflow-hidden transition-[height,opacity] duration-200 ease-out"
|
|
259
|
+
style={{
|
|
260
|
+
height: shouldShowList() ? "var(--spotlight-body-max)" : "0px",
|
|
261
|
+
opacity: shouldShowList() ? "1" : "0",
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<div class="flex h-full min-h-0 flex-col gap-2 px-3 pb-3">
|
|
265
|
+
<div class="flex h-8 items-center justify-between gap-3">
|
|
266
|
+
<div class="flex min-w-0 items-center gap-2 text-[11px] text-dimmed">
|
|
267
|
+
<Show when={parsedInput().tags.length > 0}>
|
|
268
|
+
<div class="flex items-center gap-1">
|
|
269
|
+
<For each={parsedInput().tags}>
|
|
270
|
+
{(tag) => <span class="rounded bg-zinc-200/70 px-1.5 py-0.5 text-[10px] dark:bg-zinc-800/70">#{tag}</span>}
|
|
271
|
+
</For>
|
|
272
|
+
</div>
|
|
273
|
+
</Show>
|
|
274
|
+
<span>
|
|
275
|
+
{resultItems().length} results found{" "}
|
|
276
|
+
<span aria-hidden="true">•</span>{" "}
|
|
277
|
+
<button type="button" class="text-blue-500 hover:underline dark:text-blue-400" onClick={openHelp}>
|
|
278
|
+
improve with tags
|
|
279
|
+
</button>
|
|
280
|
+
</span>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div class="min-h-0 flex-1 overflow-hidden">
|
|
285
|
+
<Show when={requestError()}>{(message) => <div class="info-block-danger mb-2 text-xs">{message()}</div>}</Show>
|
|
286
|
+
|
|
287
|
+
<div class="grid h-full min-h-0 grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_18rem]">
|
|
288
|
+
<section class="min-h-0 overflow-y-auto overscroll-y-contain pr-1" onWheel={(event) => event.stopPropagation()}>
|
|
289
|
+
<div class="flex flex-col gap-1.5">
|
|
290
|
+
<For each={resultItems()}>
|
|
291
|
+
{(row, index) => {
|
|
292
|
+
const selected = () => index() === activeIndex();
|
|
293
|
+
const item = row;
|
|
294
|
+
return (
|
|
295
|
+
<button
|
|
296
|
+
ref={(element) => bindRowRef(rowKey(item), element)}
|
|
297
|
+
type="button"
|
|
298
|
+
onMouseEnter={() => setActiveIndex(index())}
|
|
299
|
+
onClick={() => openItem(item)}
|
|
300
|
+
class="w-full rounded-xl p-2.5 text-left transition-colors"
|
|
301
|
+
classList={{
|
|
302
|
+
"bg-blue-50/85 dark:bg-blue-950/45": selected(),
|
|
303
|
+
"bg-zinc-50/75 hover:bg-zinc-100/85 dark:bg-zinc-900/45 dark:hover:bg-zinc-900/65": !selected(),
|
|
304
|
+
}}
|
|
305
|
+
>
|
|
306
|
+
<div class="flex items-start gap-2.5">
|
|
307
|
+
<i class={`${item.icon ?? item.appIcon} mt-0.5 text-[13px] text-dimmed`} />
|
|
308
|
+
<div class="min-w-0">
|
|
309
|
+
<p class="truncate text-xs">{item.title}</p>
|
|
310
|
+
<Show when={item.preview}>
|
|
311
|
+
<p class="mt-0.5 truncate text-[11px] text-dimmed">{item.preview}</p>
|
|
312
|
+
</Show>
|
|
313
|
+
<p class="mt-1 text-[10px] text-dimmed">{item.appName}</p>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</button>
|
|
317
|
+
);
|
|
318
|
+
}}
|
|
319
|
+
</For>
|
|
320
|
+
</div>
|
|
321
|
+
</section>
|
|
322
|
+
|
|
323
|
+
<aside
|
|
324
|
+
class="hidden min-h-0 overflow-y-auto overscroll-y-contain rounded-xl bg-zinc-50/80 p-3 dark:bg-zinc-900/55 md:block"
|
|
325
|
+
onWheel={(event) => event.stopPropagation()}
|
|
326
|
+
>
|
|
327
|
+
<Show when={activeItem()} fallback={<div class="text-xs text-dimmed">Select a result to preview details.</div>}>
|
|
328
|
+
{(item) => (
|
|
329
|
+
<div class="flex flex-col gap-4">
|
|
330
|
+
<div class="flex items-center gap-3">
|
|
331
|
+
<div class="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-900">
|
|
332
|
+
<Show
|
|
333
|
+
when={isValidImagePreviewUrl(item().previewUrl) && !previewFailed()}
|
|
334
|
+
fallback={<i class={`${item().icon ?? item().appIcon} text-lg text-dimmed`} />}
|
|
335
|
+
>
|
|
336
|
+
<img
|
|
337
|
+
src={item().previewUrl}
|
|
338
|
+
alt={item().title}
|
|
339
|
+
class="h-full w-full object-cover"
|
|
340
|
+
onError={() => setPreviewFailed(true)}
|
|
341
|
+
/>
|
|
342
|
+
</Show>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="min-w-0">
|
|
345
|
+
<p class="truncate text-sm">{item().title}</p>
|
|
346
|
+
<p class="mt-0.5 truncate text-xs text-dimmed">{item().appName}</p>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
<Show when={item().preview}>
|
|
351
|
+
<p class="text-xs leading-relaxed text-dimmed">{item().preview}</p>
|
|
352
|
+
</Show>
|
|
353
|
+
|
|
354
|
+
<Show when={metadataRows(item().metadata).length > 0}>
|
|
355
|
+
<div class="rounded-lg bg-zinc-100/65 p-2 dark:bg-zinc-900/65">
|
|
356
|
+
<div class="divide-y divide-zinc-200/80 dark:divide-zinc-800/80">
|
|
357
|
+
<For each={metadataRows(item().metadata)}>
|
|
358
|
+
{(entry) => (
|
|
359
|
+
<div class="grid grid-cols-[7rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs first:pt-0 last:pb-0">
|
|
360
|
+
<span class="truncate text-dimmed">{entry.label}</span>
|
|
361
|
+
<span class="truncate">{entry.value}</span>
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</For>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</Show>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</Show>
|
|
371
|
+
</aside>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export const openGlobalSearchDialog = (helpApps: GlobalSearchHelpApp[] = []) => {
|
|
381
|
+
if (dialogCore.isOpen()) return;
|
|
382
|
+
|
|
383
|
+
void dialogCore.open<void>((close) => <GlobalSearchDialog close={close} helpApps={helpApps} />, {
|
|
384
|
+
panelClassName:
|
|
385
|
+
"fixed left-1/2 top-[25vh] -translate-x-1/2 m-0 w-[min(96vw,72rem)] max-h-[50vh] overflow-hidden overscroll-y-contain rounded-2xl border-0 bg-white/92 p-0 text-zinc-900 shadow-xl ring-1 ring-inset ring-zinc-300/60 backdrop:bg-black/35 backdrop:backdrop-blur-sm dark:bg-zinc-950/92 dark:text-zinc-100 dark:ring-zinc-700/60 [@media(min-height:1100px)]:top-[33vh] [@media(min-height:1100px)]:max-h-[33vh]",
|
|
386
|
+
contentClassName: "h-full min-h-0",
|
|
387
|
+
initialFocus: "none",
|
|
388
|
+
});
|
|
389
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { For, Show, createMemo } from "solid-js";
|
|
2
|
+
import { prompts } from "../ui";
|
|
3
|
+
|
|
4
|
+
export type GlobalSearchHelpApp = {
|
|
5
|
+
appId: string;
|
|
6
|
+
appName: string;
|
|
7
|
+
appIcon: string;
|
|
8
|
+
tags: string[];
|
|
9
|
+
help?: string;
|
|
10
|
+
tagHelp?: Array<{ tag: string; help: string }>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type GlobalSearchHelpDialogProps = { apps: GlobalSearchHelpApp[] };
|
|
14
|
+
|
|
15
|
+
const examples = ["#note test", "report #file #excel", "#weather ulm"];
|
|
16
|
+
|
|
17
|
+
export default function GlobalSearchHelpDialog(props: GlobalSearchHelpDialogProps) {
|
|
18
|
+
const apps = createMemo(() =>
|
|
19
|
+
props.apps
|
|
20
|
+
.filter((app) => app.tags.length > 0)
|
|
21
|
+
.map((app) => ({
|
|
22
|
+
...app,
|
|
23
|
+
help: app.help?.trim() || undefined,
|
|
24
|
+
tags: [...new Set(app.tags.map((tag) => tag.toLowerCase()))].sort(),
|
|
25
|
+
tagHelp: [...new Map((app.tagHelp ?? []).map((entry) => [entry.tag.trim().toLowerCase(), entry.help.trim()])).entries()]
|
|
26
|
+
.filter(([tag, help]) => tag.length > 0 && help.length > 0)
|
|
27
|
+
.map(([tag, help]) => ({ tag, help })),
|
|
28
|
+
}))
|
|
29
|
+
.sort((a, b) => a.appName.localeCompare(b.appName)),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div class="flex max-h-[min(80vh,42rem)] min-h-0 flex-col gap-4 text-zinc-900 dark:text-zinc-100">
|
|
34
|
+
<div class="flex items-start justify-between gap-3">
|
|
35
|
+
<div>
|
|
36
|
+
<p class="text-sm text-dimmed">Use <code>#tag</code> to narrow your search. You can combine text + multiple tags.</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<section class="text-sm rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-3 bg-zinc-50/50 dark:bg-zinc-900/35">
|
|
41
|
+
<p class="mb-2 text-dimmed">Examples</p>
|
|
42
|
+
<ul class="space-y-1 text-xs text-dimmed">
|
|
43
|
+
<For each={examples}>{(entry) => <li><code>{entry}</code></li>}</For>
|
|
44
|
+
</ul>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
|
|
48
|
+
<div class="flex flex-col gap-2">
|
|
49
|
+
<For each={apps()}>
|
|
50
|
+
{(app) => (
|
|
51
|
+
<section class="rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-3 bg-zinc-50/50 dark:bg-zinc-900/35">
|
|
52
|
+
<header class="flex items-center gap-2 text-sm">
|
|
53
|
+
<i class={app.appIcon} />
|
|
54
|
+
<span>{app.appName}</span>
|
|
55
|
+
</header>
|
|
56
|
+
<Show when={app.help}>
|
|
57
|
+
<p class="mt-1 text-xs text-dimmed">{app.help}</p>
|
|
58
|
+
</Show>
|
|
59
|
+
<Show
|
|
60
|
+
when={(app.tagHelp?.length ?? 0) > 0}
|
|
61
|
+
fallback={
|
|
62
|
+
<p class="mt-1 text-xs text-dimmed">
|
|
63
|
+
Tags:{" "}
|
|
64
|
+
<For each={app.tags}>
|
|
65
|
+
{(tag, index) => (
|
|
66
|
+
<>
|
|
67
|
+
<code>#{tag}</code>
|
|
68
|
+
<Show when={index() < app.tags.length - 1}>, </Show>
|
|
69
|
+
</>
|
|
70
|
+
)}
|
|
71
|
+
</For>
|
|
72
|
+
</p>
|
|
73
|
+
}
|
|
74
|
+
>
|
|
75
|
+
<div class="mt-2 space-y-1 text-xs">
|
|
76
|
+
<For each={app.tagHelp}>
|
|
77
|
+
{(entry) => (
|
|
78
|
+
<div class="grid grid-cols-[6rem_minmax(0,1fr)] gap-2">
|
|
79
|
+
<code>#{entry.tag}</code>
|
|
80
|
+
<span class="text-dimmed">{entry.help}</span>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</For>
|
|
84
|
+
</div>
|
|
85
|
+
</Show>
|
|
86
|
+
</section>
|
|
87
|
+
)}
|
|
88
|
+
</For>
|
|
89
|
+
<Show when={apps().length === 0}>
|
|
90
|
+
<div class="rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-3 text-xs text-dimmed bg-zinc-50/50 dark:bg-zinc-900/35">
|
|
91
|
+
No app-specific search tags available.
|
|
92
|
+
</div>
|
|
93
|
+
</Show>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const openGlobalSearchHelpDialog = (apps: GlobalSearchHelpApp[]) => {
|
|
101
|
+
void prompts.dialog<void>(() => <GlobalSearchHelpDialog apps={apps} />, {
|
|
102
|
+
title: "Search Tags",
|
|
103
|
+
icon: "ti ti-help-circle",
|
|
104
|
+
size: "large",
|
|
105
|
+
});
|
|
106
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { hotkeys } from "@valentinkolb/stdlib/solid";
|
|
2
|
+
import { openGlobalSearchDialog } from "./GlobalSearchDialog";
|
|
3
|
+
import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
4
|
+
|
|
5
|
+
type GlobalSearchTriggerProps = {
|
|
6
|
+
variant: "header" | "rail";
|
|
7
|
+
class?: string;
|
|
8
|
+
registerHotkey?: boolean;
|
|
9
|
+
searchHelpApps?: GlobalSearchHelpApp[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Opens the spotlight-style global search dialog from nav/header trigger points. */
|
|
13
|
+
export default function GlobalSearchTrigger(props: GlobalSearchTriggerProps) {
|
|
14
|
+
const searchHelpApps = props.searchHelpApps ?? [];
|
|
15
|
+
|
|
16
|
+
if (props.registerHotkey) {
|
|
17
|
+
hotkeys.create(() => ({
|
|
18
|
+
"mod+k": {
|
|
19
|
+
label: "Open global search",
|
|
20
|
+
desc: "Search across apps, pages, files, and items.",
|
|
21
|
+
run: () => openGlobalSearchDialog(searchHelpApps),
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const triggerClass =
|
|
27
|
+
props.variant === "rail"
|
|
28
|
+
? `rail-item text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 hover:bg-blue-500/10 dark:hover:bg-blue-500/15 ${props.class ?? ""}`
|
|
29
|
+
: `icon-btn inline ${props.class ?? ""}`;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
class={triggerClass}
|
|
35
|
+
onClick={() => openGlobalSearchDialog(searchHelpApps)}
|
|
36
|
+
aria-label="Open global search"
|
|
37
|
+
title="Search (Mod+K)"
|
|
38
|
+
>
|
|
39
|
+
<i class="ti ti-search text-base" />
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { For, Show, createMemo } from "solid-js";
|
|
2
|
+
import { hotkeys } from "@valentinkolb/stdlib/solid";
|
|
3
|
+
import { prompts } from "../ui";
|
|
4
|
+
import { openGlobalSearchHelpDialog, type GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
5
|
+
|
|
6
|
+
const ShortcutsDialog = (props: { openSearchHelp: () => void }) => {
|
|
7
|
+
const entries = createMemo(() =>
|
|
8
|
+
[...hotkeys.entries()].sort((a, b) => {
|
|
9
|
+
const labelSort = a.label.localeCompare(b.label);
|
|
10
|
+
return labelSort !== 0 ? labelSort : a.keys.localeCompare(b.keys);
|
|
11
|
+
}),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div class="flex flex-col gap-3">
|
|
16
|
+
<p class="text-xs text-dimmed leading-relaxed">
|
|
17
|
+
Use these keyboard shortcuts to work faster. The list updates automatically depending on which app or view is currently open.
|
|
18
|
+
</p>
|
|
19
|
+
<p class="text-xs text-dimmed leading-relaxed">
|
|
20
|
+
Looking for the Spotlight/search{" "}
|
|
21
|
+
<button type="button" class="text-blue-500 hover:underline dark:text-blue-400" onClick={props.openSearchHelp}>
|
|
22
|
+
help
|
|
23
|
+
</button>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<div class="max-h-[60vh] overflow-y-auto pr-1">
|
|
27
|
+
<div class="flex flex-col gap-2">
|
|
28
|
+
<For each={entries()}>
|
|
29
|
+
{(entry) => (
|
|
30
|
+
<div class="rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-2.5 bg-zinc-50/50 dark:bg-zinc-900/35">
|
|
31
|
+
<div class="flex items-start justify-between gap-3">
|
|
32
|
+
<div class="min-w-0">
|
|
33
|
+
<p class="text-sm font-medium text-primary truncate">{entry.label}</p>
|
|
34
|
+
<p class="text-xs text-dimmed mt-0.5">{entry.desc || "No description provided."}</p>
|
|
35
|
+
</div>
|
|
36
|
+
<div
|
|
37
|
+
class="flex items-center gap-1.5 shrink-0"
|
|
38
|
+
role="group"
|
|
39
|
+
aria-label={entry.keysPretty.map((part) => part.ariaLabel).join(" + ")}
|
|
40
|
+
>
|
|
41
|
+
<For each={entry.keysPretty}>
|
|
42
|
+
{(part) => (
|
|
43
|
+
<kbd class="inline-flex min-w-6 justify-center px-1.5 py-1 rounded-md text-[11px] leading-none font-medium ring-1 ring-inset ring-zinc-300 dark:ring-zinc-700 bg-white dark:bg-zinc-900 text-primary">
|
|
44
|
+
{part.key}
|
|
45
|
+
</kbd>
|
|
46
|
+
)}
|
|
47
|
+
</For>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
</For>
|
|
53
|
+
<Show when={entries().length === 0}>
|
|
54
|
+
<div class="rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-3 text-xs text-dimmed bg-zinc-50/50 dark:bg-zinc-900/35">
|
|
55
|
+
No shortcuts registered yet.
|
|
56
|
+
</div>
|
|
57
|
+
</Show>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Help action in rail nav: opens a modal with all currently registered hotkeys. */
|
|
66
|
+
export default function HotkeysHelpRail(props: { searchHelpApps?: GlobalSearchHelpApp[] }) {
|
|
67
|
+
const searchHelpApps = props.searchHelpApps ?? [];
|
|
68
|
+
|
|
69
|
+
const openHelp = () => {
|
|
70
|
+
void prompts.dialog<void>((close) => <ShortcutsDialog openSearchHelp={() => {
|
|
71
|
+
close();
|
|
72
|
+
queueMicrotask(() => openGlobalSearchHelpDialog(searchHelpApps));
|
|
73
|
+
}} />, {
|
|
74
|
+
title: "Keyboard Shortcuts",
|
|
75
|
+
icon: "ti ti-keyboard",
|
|
76
|
+
size: "large",
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
hotkeys.create(() => ({
|
|
81
|
+
"shift+/": {
|
|
82
|
+
label: "Open shortcut help",
|
|
83
|
+
desc: "Show all currently registered keyboard shortcuts.",
|
|
84
|
+
run: openHelp,
|
|
85
|
+
},
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
class="rail-item text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 hover:bg-blue-500/10 dark:hover:bg-blue-500/15"
|
|
92
|
+
onClick={openHelp}
|
|
93
|
+
aria-label="Open keyboard shortcuts help"
|
|
94
|
+
title="Keyboard shortcuts (Shift+/)"
|
|
95
|
+
>
|
|
96
|
+
<i class="ti ti-help-circle text-base" />
|
|
97
|
+
</button>
|
|
98
|
+
);
|
|
99
|
+
}
|