@valentinkolb/cloud 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +116 -13
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +47 -7
- package/src/services/auth-flows/magic-link.ts +92 -20
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/notifications/index.ts +82 -11
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +79 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +58 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -26,6 +26,7 @@ type SearchResponse = {
|
|
|
26
26
|
query: string;
|
|
27
27
|
count: number;
|
|
28
28
|
items: SearchItem[];
|
|
29
|
+
unsupportedTags?: string[];
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
type ParsedInput = {
|
|
@@ -38,6 +39,12 @@ type GlobalSearchDialogProps = {
|
|
|
38
39
|
helpApps: GlobalSearchHelpApp[];
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
type TagSuggestion = {
|
|
43
|
+
tag: string;
|
|
44
|
+
appName: string;
|
|
45
|
+
appIcon: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
41
48
|
const PROVIDER_LIMIT = 10;
|
|
42
49
|
const MIN_QUERY_LENGTH = 2;
|
|
43
50
|
const SEARCH_DEBOUNCE_MS = 200;
|
|
@@ -51,6 +58,60 @@ const sortByPriorityAndTitle = (a: SearchItem, b: SearchItem) => {
|
|
|
51
58
|
return a.title.localeCompare(b.title);
|
|
52
59
|
};
|
|
53
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Cluster items by their owning app while preserving the global priority
|
|
63
|
+
* order across groups: the first occurrence of each app fixes its slot, and
|
|
64
|
+
* items within a group keep their original (already sorted) order. Map
|
|
65
|
+
* iteration order in JS is insertion order, so this is stable.
|
|
66
|
+
*/
|
|
67
|
+
const groupByApp = (items: SearchItem[]): SearchItem[] => {
|
|
68
|
+
const groups = new Map<string, SearchItem[]>();
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
const arr = groups.get(item.appId);
|
|
71
|
+
if (arr) arr.push(item);
|
|
72
|
+
else groups.set(item.appId, [item]);
|
|
73
|
+
}
|
|
74
|
+
const out: SearchItem[] = [];
|
|
75
|
+
for (const group of groups.values()) out.push(...group);
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type ListEntry =
|
|
80
|
+
| { kind: "header"; appId: string; appName: string; appIcon: string; count: number }
|
|
81
|
+
| { kind: "item"; item: SearchItem; flatIndex: number };
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the flat render list with optional group headers interleaved.
|
|
85
|
+
* Headers only appear when 2+ apps have results (single-app queries stay
|
|
86
|
+
* uncluttered). `flatIndex` on each item entry mirrors the index into the
|
|
87
|
+
* underlying sorted list — used for selection/scroll/keyboard nav.
|
|
88
|
+
*/
|
|
89
|
+
const buildListEntries = (items: SearchItem[]): ListEntry[] => {
|
|
90
|
+
if (items.length === 0) return [];
|
|
91
|
+
|
|
92
|
+
const counts = new Map<string, number>();
|
|
93
|
+
for (const item of items) counts.set(item.appId, (counts.get(item.appId) ?? 0) + 1);
|
|
94
|
+
const showHeaders = counts.size >= 2;
|
|
95
|
+
|
|
96
|
+
const entries: ListEntry[] = [];
|
|
97
|
+
let prevAppId: string | null = null;
|
|
98
|
+
for (let i = 0; i < items.length; i++) {
|
|
99
|
+
const item = items[i]!;
|
|
100
|
+
if (showHeaders && item.appId !== prevAppId) {
|
|
101
|
+
entries.push({
|
|
102
|
+
kind: "header",
|
|
103
|
+
appId: item.appId,
|
|
104
|
+
appName: item.appName,
|
|
105
|
+
appIcon: item.appIcon,
|
|
106
|
+
count: counts.get(item.appId) ?? 0,
|
|
107
|
+
});
|
|
108
|
+
prevAppId = item.appId;
|
|
109
|
+
}
|
|
110
|
+
entries.push({ kind: "item", item, flatIndex: i });
|
|
111
|
+
}
|
|
112
|
+
return entries;
|
|
113
|
+
};
|
|
114
|
+
|
|
54
115
|
const parseInput = (raw: string): ParsedInput => {
|
|
55
116
|
const tokens = raw.trim().split(/\s+/).filter(Boolean);
|
|
56
117
|
const tags: string[] = [];
|
|
@@ -74,6 +135,13 @@ const parseInput = (raw: string): ParsedInput => {
|
|
|
74
135
|
};
|
|
75
136
|
};
|
|
76
137
|
|
|
138
|
+
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
139
|
+
|
|
140
|
+
const removeTagFromInput = (raw: string, tag: string): string => {
|
|
141
|
+
const pattern = new RegExp(`(^|\\s)#${escapeRegex(tag)}(?=\\s|$)`, "gi");
|
|
142
|
+
return raw.replace(pattern, "").replace(/\s+/g, " ").trim();
|
|
143
|
+
};
|
|
144
|
+
|
|
77
145
|
const metadataRows = (metadata?: SearchMetadata[]) =>
|
|
78
146
|
(metadata ?? [])
|
|
79
147
|
.filter((entry) => entry.label.trim().length > 0 && entry.value.trim().length > 0)
|
|
@@ -82,9 +150,13 @@ const metadataRows = (metadata?: SearchMetadata[]) =>
|
|
|
82
150
|
export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
83
151
|
const [rawInput, setRawInput] = createSignal("");
|
|
84
152
|
const [resultItems, setResultItems] = createSignal<SearchItem[]>([]);
|
|
153
|
+
const [unsupportedTags, setUnsupportedTags] = createSignal<string[]>([]);
|
|
85
154
|
const [activeIndex, setActiveIndex] = createSignal(0);
|
|
86
155
|
const [previewFailed, setPreviewFailed] = createSignal(false);
|
|
87
156
|
const [requestError, setRequestError] = createSignal<string | null>(null);
|
|
157
|
+
const [hasReceivedResponse, setHasReceivedResponse] = createSignal(false);
|
|
158
|
+
const [cursorPos, setCursorPos] = createSignal(0);
|
|
159
|
+
const [suggestionIndex, setSuggestionIndex] = createSignal(0);
|
|
88
160
|
|
|
89
161
|
let inputRef: HTMLInputElement | undefined;
|
|
90
162
|
const rowRefs = new Map<string, HTMLButtonElement>();
|
|
@@ -93,7 +165,113 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
|
93
165
|
const canSearch = createMemo(
|
|
94
166
|
() => parsedInput().tags.length > 0 || parsedInput().query.length >= MIN_QUERY_LENGTH,
|
|
95
167
|
);
|
|
96
|
-
|
|
168
|
+
|
|
169
|
+
// Tag suggestions for the empty state — flat list of every tag declared by
|
|
170
|
+
// any app, deduped on tag name (first declaration wins) so we don't render
|
|
171
|
+
// the same chip twice when two apps share a tag.
|
|
172
|
+
const tagSuggestions = createMemo<TagSuggestion[]>(() => {
|
|
173
|
+
const seen = new Set<string>();
|
|
174
|
+
const out: TagSuggestion[] = [];
|
|
175
|
+
for (const app of props.helpApps) {
|
|
176
|
+
for (const tag of app.tags) {
|
|
177
|
+
const lower = tag.toLowerCase();
|
|
178
|
+
if (seen.has(lower)) continue;
|
|
179
|
+
seen.add(lower);
|
|
180
|
+
out.push({ tag: lower, appName: app.appName, appIcon: app.appIcon });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Detect whether the caret is currently inside a tag token (i.e. the user
|
|
188
|
+
* is mid-typing `#...`). Returns the token's start/end indices in rawInput
|
|
189
|
+
* plus the lowercased prefix typed after `#`. Returns null otherwise — no
|
|
190
|
+
* autocomplete popover should show.
|
|
191
|
+
*
|
|
192
|
+
* Walks back from the cursor to the last whitespace; if the token starts
|
|
193
|
+
* with `#`, we're in tag-input mode. If the prefix contains a non-tag
|
|
194
|
+
* character (`#` or whitespace), bails — the parser would reject it
|
|
195
|
+
* anyway, so suggestions would be misleading.
|
|
196
|
+
*/
|
|
197
|
+
const tagContext = createMemo(() => {
|
|
198
|
+
const text = rawInput();
|
|
199
|
+
const pos = Math.min(cursorPos(), text.length);
|
|
200
|
+
let start = pos;
|
|
201
|
+
while (start > 0 && !/\s/.test(text[start - 1] ?? "")) start--;
|
|
202
|
+
// Extend to the next whitespace so we replace the WHOLE token, not just
|
|
203
|
+
// up to the caret. Otherwise editing in the middle of an existing tag
|
|
204
|
+
// (e.g. caret after `#no` in `#notebook`) leaves `tebook` dangling after
|
|
205
|
+
// the inserted suggestion. Prefix used for matching is still the part
|
|
206
|
+
// BEFORE the caret — that's what the user has committed to typing.
|
|
207
|
+
let end = pos;
|
|
208
|
+
while (end < text.length && !/\s/.test(text[end] ?? "")) end++;
|
|
209
|
+
const fullToken = text.slice(start, end);
|
|
210
|
+
if (!fullToken.startsWith("#")) return null;
|
|
211
|
+
const prefix = text.slice(start + 1, pos).toLowerCase();
|
|
212
|
+
if (prefix.length > 0 && !TAG_TOKEN_PATTERN.test(prefix)) return null;
|
|
213
|
+
return { start, end, prefix };
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Tags filtered by the current prefix, with already-active tags excluded
|
|
218
|
+
* (no point suggesting one the user has). Starts-with match for predictability.
|
|
219
|
+
*/
|
|
220
|
+
const filteredSuggestions = createMemo<TagSuggestion[]>(() => {
|
|
221
|
+
const ctx = tagContext();
|
|
222
|
+
if (!ctx) return [];
|
|
223
|
+
const active = new Set(parsedInput().tags);
|
|
224
|
+
const all = tagSuggestions().filter((s) => !active.has(s.tag));
|
|
225
|
+
if (ctx.prefix.length === 0) return all;
|
|
226
|
+
return all.filter((s) => s.tag.startsWith(ctx.prefix));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const popoverOpen = createMemo(() => filteredSuggestions().length > 0);
|
|
230
|
+
|
|
231
|
+
// Reset highlight when the filtered list changes shape (new prefix, etc.).
|
|
232
|
+
createEffect(() => {
|
|
233
|
+
filteredSuggestions();
|
|
234
|
+
setSuggestionIndex(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const updateCursor = () => {
|
|
238
|
+
if (!inputRef) return;
|
|
239
|
+
setCursorPos(inputRef.selectionStart ?? rawInput().length);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const insertTagAtCursor = (tag: string) => {
|
|
243
|
+
const ctx = tagContext();
|
|
244
|
+
const text = rawInput();
|
|
245
|
+
if (!ctx) {
|
|
246
|
+
// No tag context — append. Used by suggestion-mode chips.
|
|
247
|
+
if (parsedInput().tags.includes(tag)) {
|
|
248
|
+
inputRef?.focus();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const next = text.length > 0 ? `${text.trimEnd()} #${tag} ` : `#${tag} `;
|
|
252
|
+
setRawInput(next);
|
|
253
|
+
const newPos = next.length;
|
|
254
|
+
queueMicrotask(() => {
|
|
255
|
+
if (inputRef) inputRef.setSelectionRange(newPos, newPos);
|
|
256
|
+
setCursorPos(newPos);
|
|
257
|
+
});
|
|
258
|
+
inputRef?.focus();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const before = text.slice(0, ctx.start);
|
|
262
|
+
const after = text.slice(ctx.end).replace(/^\s+/, "");
|
|
263
|
+
// Always emit `#tag ` with one trailing space so the user can keep
|
|
264
|
+
// typing the next token without manual spacing.
|
|
265
|
+
const insertion = `#${tag} `;
|
|
266
|
+
const next = before + insertion + after;
|
|
267
|
+
setRawInput(next);
|
|
268
|
+
const newPos = before.length + insertion.length;
|
|
269
|
+
queueMicrotask(() => {
|
|
270
|
+
if (inputRef) inputRef.setSelectionRange(newPos, newPos);
|
|
271
|
+
setCursorPos(newPos);
|
|
272
|
+
});
|
|
273
|
+
inputRef?.focus();
|
|
274
|
+
};
|
|
97
275
|
|
|
98
276
|
const searchMutation = mutation.create<SearchResponse, ParsedInput>({
|
|
99
277
|
mutation: async (input, ctx) => {
|
|
@@ -113,23 +291,46 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
|
113
291
|
return payload as SearchResponse;
|
|
114
292
|
},
|
|
115
293
|
onSuccess: (payload) => {
|
|
116
|
-
|
|
294
|
+
const sorted = (payload.items ?? []).slice().sort(sortByPriorityAndTitle);
|
|
295
|
+
setResultItems(groupByApp(sorted));
|
|
296
|
+
setUnsupportedTags(payload.unsupportedTags ?? []);
|
|
117
297
|
setActiveIndex(0);
|
|
118
298
|
setRequestError(null);
|
|
299
|
+
setHasReceivedResponse(true);
|
|
119
300
|
},
|
|
120
301
|
onError: (error) => {
|
|
121
302
|
if (error.name === "AbortError") return;
|
|
122
303
|
setResultItems([]);
|
|
304
|
+
setUnsupportedTags([]);
|
|
123
305
|
setActiveIndex(0);
|
|
124
306
|
setRequestError(error.message || "Search failed.");
|
|
307
|
+
setHasReceivedResponse(true);
|
|
125
308
|
},
|
|
126
309
|
});
|
|
127
310
|
|
|
128
311
|
const activeItem = createMemo(() => resultItems()[activeIndex()] ?? null);
|
|
312
|
+
const listEntries = createMemo(() => buildListEntries(resultItems()));
|
|
313
|
+
|
|
314
|
+
// Empty-state branching. Single source of truth for what the body renders.
|
|
315
|
+
// - suggestions: user hasn't typed enough to search; show what's possible.
|
|
316
|
+
// - unsupported-tags: response told us no app accepts these tags.
|
|
317
|
+
// - no-results: search ran, returned nothing.
|
|
318
|
+
// - ready: render results.
|
|
319
|
+
// - idle: search debounced or in flight, no decision yet.
|
|
320
|
+
type BodyMode = "suggestions" | "unsupported-tags" | "no-results" | "ready" | "idle";
|
|
321
|
+
const bodyMode = createMemo<BodyMode>(() => {
|
|
322
|
+
if (!canSearch()) return "suggestions";
|
|
323
|
+
if (resultItems().length > 0) return "ready";
|
|
324
|
+
if (searchMutation.loading() || !hasReceivedResponse()) return "idle";
|
|
325
|
+
if (unsupportedTags().length > 0) return "unsupported-tags";
|
|
326
|
+
return "no-results";
|
|
327
|
+
});
|
|
129
328
|
|
|
130
329
|
const { debouncedFn: debounceSearch, cancel: cancelDebounce } = timed.debounce((input: ParsedInput) => {
|
|
131
330
|
setResultItems([]);
|
|
331
|
+
setUnsupportedTags([]);
|
|
132
332
|
setActiveIndex(0);
|
|
333
|
+
setHasReceivedResponse(false);
|
|
133
334
|
searchMutation.abort();
|
|
134
335
|
void searchMutation.mutate(input);
|
|
135
336
|
}, SEARCH_DEBOUNCE_MS);
|
|
@@ -162,7 +363,56 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
|
162
363
|
setActiveIndex(next);
|
|
163
364
|
};
|
|
164
365
|
|
|
366
|
+
const removeTag = (tag: string) => {
|
|
367
|
+
setRawInput((prev) => removeTagFromInput(prev, tag));
|
|
368
|
+
inputRef?.focus();
|
|
369
|
+
};
|
|
370
|
+
|
|
165
371
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
372
|
+
// Tag autocomplete intercepts navigation when the popover is open. Only
|
|
373
|
+
// reaches results-list nav when no suggestion is being chosen.
|
|
374
|
+
if (popoverOpen()) {
|
|
375
|
+
if (event.key === "ArrowDown") {
|
|
376
|
+
event.preventDefault();
|
|
377
|
+
const list = filteredSuggestions();
|
|
378
|
+
setSuggestionIndex((i) => (i + 1) % list.length);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (event.key === "ArrowUp") {
|
|
382
|
+
event.preventDefault();
|
|
383
|
+
const list = filteredSuggestions();
|
|
384
|
+
setSuggestionIndex((i) => (i - 1 + list.length) % list.length);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
388
|
+
event.preventDefault();
|
|
389
|
+
const list = filteredSuggestions();
|
|
390
|
+
const choice = list[suggestionIndex()];
|
|
391
|
+
if (choice) insertTagAtCursor(choice.tag);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (event.key === "Escape") {
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
// Close popover by erasing the in-flight tag token (caret-prefixed
|
|
397
|
+
// `#xyz`) — leaves the rest of the query intact. If the user wants
|
|
398
|
+
// to keep `#xyz`, they can press Space instead.
|
|
399
|
+
const ctx = tagContext();
|
|
400
|
+
if (ctx) {
|
|
401
|
+
const text = rawInput();
|
|
402
|
+
const next = text.slice(0, ctx.start) + text.slice(ctx.end).replace(/^\s+/, "");
|
|
403
|
+
setRawInput(next);
|
|
404
|
+
queueMicrotask(() => {
|
|
405
|
+
if (inputRef) inputRef.setSelectionRange(ctx.start, ctx.start);
|
|
406
|
+
setCursorPos(ctx.start);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// Space commits the current token as-is and closes the popover by
|
|
412
|
+
// virtue of advancing the cursor past `#`. No special handling needed —
|
|
413
|
+
// tagContext recomputes naturally.
|
|
414
|
+
}
|
|
415
|
+
|
|
166
416
|
if (event.key === "ArrowDown") {
|
|
167
417
|
event.preventDefault();
|
|
168
418
|
moveSelection(1);
|
|
@@ -211,7 +461,9 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
|
211
461
|
cancelDebounce();
|
|
212
462
|
searchMutation.abort();
|
|
213
463
|
setResultItems([]);
|
|
464
|
+
setUnsupportedTags([]);
|
|
214
465
|
setActiveIndex(0);
|
|
466
|
+
setHasReceivedResponse(false);
|
|
215
467
|
return;
|
|
216
468
|
}
|
|
217
469
|
|
|
@@ -232,144 +484,320 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
|
|
|
232
484
|
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
485
|
onWheel={(event) => event.stopPropagation()}
|
|
234
486
|
>
|
|
235
|
-
<
|
|
236
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
487
|
+
<div class="relative">
|
|
488
|
+
<label class="flex items-center gap-3 px-4 py-3.5">
|
|
489
|
+
<i class="ti ti-search text-xl text-dimmed" />
|
|
490
|
+
<input
|
|
491
|
+
id="spotlight-input"
|
|
492
|
+
ref={inputRef}
|
|
493
|
+
type="search"
|
|
494
|
+
value={rawInput()}
|
|
495
|
+
onInput={(event) => {
|
|
496
|
+
setRawInput(event.currentTarget.value);
|
|
497
|
+
setCursorPos(event.currentTarget.selectionStart ?? event.currentTarget.value.length);
|
|
498
|
+
}}
|
|
499
|
+
onKeyUp={updateCursor}
|
|
500
|
+
onClick={updateCursor}
|
|
501
|
+
onSelect={updateCursor}
|
|
502
|
+
onKeyDown={handleKeyDown}
|
|
503
|
+
placeholder="Search across apps..."
|
|
504
|
+
aria-label="Global search"
|
|
505
|
+
class="w-full border-0 bg-transparent text-base outline-none placeholder:text-dimmed md:text-lg"
|
|
506
|
+
spellcheck={false}
|
|
507
|
+
autocapitalize="off"
|
|
508
|
+
autocomplete="off"
|
|
509
|
+
autocorrect="off"
|
|
510
|
+
/>
|
|
511
|
+
<Show when={searchMutation.loading()}>
|
|
512
|
+
<i class="ti ti-loader-2 animate-spin text-dimmed" />
|
|
513
|
+
</Show>
|
|
514
|
+
</label>
|
|
515
|
+
|
|
516
|
+
{/* Tag autocomplete popover. Anchored to the input row, overlays the
|
|
517
|
+
body when the user is mid-typing a `#tag` token. Keyboard nav
|
|
518
|
+
(Up/Down/Tab/Enter/Escape) is intercepted by handleKeyDown. */}
|
|
519
|
+
<Show when={popoverOpen()}>
|
|
520
|
+
<div
|
|
521
|
+
class="absolute left-3 right-3 top-full z-30 -mt-1 max-h-64 overflow-y-auto overscroll-contain rounded-xl bg-white/95 p-1.5 shadow-lg ring-1 ring-inset ring-zinc-300/60 backdrop-blur-sm dark:bg-zinc-900/95 dark:ring-zinc-700/60"
|
|
522
|
+
role="listbox"
|
|
523
|
+
aria-label="Tag suggestions"
|
|
524
|
+
>
|
|
525
|
+
<For each={filteredSuggestions()}>
|
|
526
|
+
{(suggestion, index) => {
|
|
527
|
+
const selected = () => index() === suggestionIndex();
|
|
528
|
+
return (
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
role="option"
|
|
532
|
+
aria-selected={selected()}
|
|
533
|
+
onMouseEnter={() => setSuggestionIndex(index())}
|
|
534
|
+
onMouseDown={(e) => {
|
|
535
|
+
// mousedown so the input doesn't lose focus to the click.
|
|
536
|
+
e.preventDefault();
|
|
537
|
+
insertTagAtCursor(suggestion.tag);
|
|
538
|
+
}}
|
|
539
|
+
class="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors"
|
|
540
|
+
classList={{
|
|
541
|
+
"bg-blue-50/85 dark:bg-blue-950/45": selected(),
|
|
542
|
+
"hover:bg-zinc-100/85 dark:hover:bg-zinc-800/60": !selected(),
|
|
543
|
+
}}
|
|
544
|
+
>
|
|
545
|
+
<i class={`${suggestion.appIcon} text-[12px] text-dimmed`} />
|
|
546
|
+
<span class="font-medium">#{suggestion.tag}</span>
|
|
547
|
+
<span class="text-[10px] text-dimmed">{suggestion.appName}</span>
|
|
548
|
+
</button>
|
|
549
|
+
);
|
|
550
|
+
}}
|
|
551
|
+
</For>
|
|
552
|
+
</div>
|
|
254
553
|
</Show>
|
|
255
|
-
</
|
|
554
|
+
</div>
|
|
256
555
|
|
|
257
556
|
<div
|
|
258
557
|
class="overflow-hidden transition-[height,opacity] duration-200 ease-out"
|
|
259
558
|
style={{
|
|
260
|
-
height:
|
|
261
|
-
opacity:
|
|
559
|
+
height: "var(--spotlight-body-max)",
|
|
560
|
+
opacity: "1",
|
|
262
561
|
}}
|
|
263
562
|
>
|
|
264
563
|
<div class="flex h-full min-h-0 flex-col gap-2 px-3 pb-3">
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
564
|
+
{/* Active-tag chip row + meta. Hidden in suggestions mode. */}
|
|
565
|
+
<Show when={bodyMode() !== "suggestions"}>
|
|
566
|
+
<div class="flex h-8 items-center justify-between gap-3">
|
|
567
|
+
<div class="flex min-w-0 flex-wrap items-center gap-2 text-[11px] text-dimmed">
|
|
568
|
+
<Show when={parsedInput().tags.length > 0}>
|
|
569
|
+
<div class="flex flex-wrap items-center gap-1">
|
|
570
|
+
<For each={parsedInput().tags}>
|
|
571
|
+
{(tag) => {
|
|
572
|
+
const isUnsupported = () => unsupportedTags().includes(tag);
|
|
573
|
+
return (
|
|
574
|
+
<span
|
|
575
|
+
class="group inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px]"
|
|
576
|
+
classList={{
|
|
577
|
+
"bg-zinc-200/70 dark:bg-zinc-800/70": !isUnsupported(),
|
|
578
|
+
"bg-amber-200/60 text-amber-900 dark:bg-amber-900/35 dark:text-amber-200": isUnsupported(),
|
|
579
|
+
}}
|
|
580
|
+
>
|
|
581
|
+
#{tag}
|
|
582
|
+
<button
|
|
583
|
+
type="button"
|
|
584
|
+
class="opacity-60 hover:opacity-100"
|
|
585
|
+
onClick={() => removeTag(tag)}
|
|
586
|
+
aria-label={`Remove tag ${tag}`}
|
|
587
|
+
title={`Remove #${tag}`}
|
|
588
|
+
>
|
|
589
|
+
<i class="ti ti-x text-[10px]" />
|
|
590
|
+
</button>
|
|
591
|
+
</span>
|
|
592
|
+
);
|
|
593
|
+
}}
|
|
594
|
+
</For>
|
|
595
|
+
</div>
|
|
596
|
+
</Show>
|
|
597
|
+
<Show when={bodyMode() === "ready"}>
|
|
598
|
+
<span>
|
|
599
|
+
{resultItems().length} result{resultItems().length === 1 ? "" : "s"}{" "}
|
|
600
|
+
<span aria-hidden="true">•</span>{" "}
|
|
601
|
+
<button type="button" class="text-blue-500 hover:underline dark:text-blue-400" onClick={openHelp}>
|
|
602
|
+
tag help
|
|
603
|
+
</button>
|
|
604
|
+
</span>
|
|
605
|
+
</Show>
|
|
606
|
+
</div>
|
|
281
607
|
</div>
|
|
282
|
-
</
|
|
608
|
+
</Show>
|
|
283
609
|
|
|
284
610
|
<div class="min-h-0 flex-1 overflow-hidden">
|
|
285
611
|
<Show when={requestError()}>{(message) => <div class="info-block-danger mb-2 text-xs">{message()}</div>}</Show>
|
|
286
612
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
613
|
+
{/* Suggestions: empty input, show available tags as clickable chips. */}
|
|
614
|
+
<Show when={bodyMode() === "suggestions"}>
|
|
615
|
+
<div class="flex h-full min-h-0 flex-col gap-3 overflow-y-auto pr-1">
|
|
616
|
+
<p class="text-xs text-dimmed">Type to search, or use a <code class="rounded bg-zinc-100 px-1 py-0.5 text-[10px] dark:bg-zinc-900">#tag</code> to focus on one app.</p>
|
|
617
|
+
<Show
|
|
618
|
+
when={tagSuggestions().length > 0}
|
|
619
|
+
fallback={<p class="text-xs text-dimmed">No tags available.</p>}
|
|
620
|
+
>
|
|
621
|
+
<div class="flex flex-wrap gap-1.5">
|
|
622
|
+
<For each={tagSuggestions()}>
|
|
623
|
+
{(suggestion) => (
|
|
295
624
|
<button
|
|
296
|
-
ref={(element) => bindRowRef(rowKey(item), element)}
|
|
297
625
|
type="button"
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
}}
|
|
626
|
+
onClick={() => insertTagAtCursor(suggestion.tag)}
|
|
627
|
+
class="inline-flex items-center gap-1.5 rounded-full bg-zinc-100/80 px-2.5 py-1 text-[11px] text-zinc-700 transition-colors hover:bg-zinc-200/80 dark:bg-zinc-900/55 dark:text-zinc-200 dark:hover:bg-zinc-800/80"
|
|
628
|
+
title={`Add #${suggestion.tag} (${suggestion.appName})`}
|
|
305
629
|
>
|
|
306
|
-
<
|
|
307
|
-
|
|
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>
|
|
630
|
+
<i class={`${suggestion.appIcon} text-[11px] text-dimmed`} />
|
|
631
|
+
<span>#{suggestion.tag}</span>
|
|
316
632
|
</button>
|
|
317
|
-
)
|
|
318
|
-
|
|
633
|
+
)}
|
|
634
|
+
</For>
|
|
635
|
+
</div>
|
|
636
|
+
</Show>
|
|
637
|
+
<button
|
|
638
|
+
type="button"
|
|
639
|
+
class="self-start text-[11px] text-blue-500 hover:underline dark:text-blue-400"
|
|
640
|
+
onClick={openHelp}
|
|
641
|
+
>
|
|
642
|
+
See all tag descriptions
|
|
643
|
+
</button>
|
|
644
|
+
</div>
|
|
645
|
+
</Show>
|
|
646
|
+
|
|
647
|
+
{/* Unsupported tags: response told us no app handles them. */}
|
|
648
|
+
<Show when={bodyMode() === "unsupported-tags"}>
|
|
649
|
+
<div class="flex h-full min-h-0 flex-col items-center justify-center gap-3 text-center">
|
|
650
|
+
<i class="ti ti-tag-off text-2xl text-dimmed" />
|
|
651
|
+
<div class="flex flex-col gap-1">
|
|
652
|
+
<p class="text-xs">
|
|
653
|
+
No app supports{" "}
|
|
654
|
+
<For each={unsupportedTags()}>
|
|
655
|
+
{(tag, index) => (
|
|
656
|
+
<>
|
|
657
|
+
<code class="rounded bg-amber-200/60 px-1 py-0.5 text-[10px] text-amber-900 dark:bg-amber-900/35 dark:text-amber-200">#{tag}</code>
|
|
658
|
+
<Show when={index() < unsupportedTags().length - 1}>{" "}</Show>
|
|
659
|
+
</>
|
|
660
|
+
)}
|
|
661
|
+
</For>
|
|
662
|
+
.
|
|
663
|
+
</p>
|
|
664
|
+
<p class="text-[11px] text-dimmed">Remove the tag or pick one below.</p>
|
|
665
|
+
</div>
|
|
666
|
+
<div class="flex flex-wrap items-center justify-center gap-1.5">
|
|
667
|
+
<For each={unsupportedTags()}>
|
|
668
|
+
{(tag) => (
|
|
669
|
+
<button
|
|
670
|
+
type="button"
|
|
671
|
+
onClick={() => removeTag(tag)}
|
|
672
|
+
class="inline-flex items-center gap-1 rounded-full bg-amber-200/60 px-2.5 py-1 text-[11px] text-amber-900 hover:bg-amber-300/60 dark:bg-amber-900/35 dark:text-amber-200 dark:hover:bg-amber-900/55"
|
|
673
|
+
>
|
|
674
|
+
<span>Remove #{tag}</span>
|
|
675
|
+
<i class="ti ti-x text-[10px]" />
|
|
676
|
+
</button>
|
|
677
|
+
)}
|
|
319
678
|
</For>
|
|
320
679
|
</div>
|
|
321
|
-
</
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
>
|
|
327
|
-
<
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
680
|
+
</div>
|
|
681
|
+
</Show>
|
|
682
|
+
|
|
683
|
+
{/* No matches at all (query/tags valid, just empty result set). */}
|
|
684
|
+
<Show when={bodyMode() === "no-results"}>
|
|
685
|
+
<div class="flex h-full min-h-0 flex-col items-center justify-center gap-2 text-center">
|
|
686
|
+
<i class="ti ti-mood-empty text-2xl text-dimmed" />
|
|
687
|
+
<p class="text-xs text-dimmed">
|
|
688
|
+
No matches{parsedInput().query.length > 0 ? <> for <span class="text-zinc-700 dark:text-zinc-200">"{parsedInput().query}"</span></> : null}.
|
|
689
|
+
</p>
|
|
690
|
+
</div>
|
|
691
|
+
</Show>
|
|
692
|
+
|
|
693
|
+
{/* Idle (debounce / loading first response) — keep the area quiet. */}
|
|
694
|
+
<Show when={bodyMode() === "idle"}>
|
|
695
|
+
<div class="flex h-full min-h-0 flex-col items-center justify-center text-dimmed">
|
|
696
|
+
<i class="ti ti-loader-2 animate-spin text-base" />
|
|
697
|
+
</div>
|
|
698
|
+
</Show>
|
|
699
|
+
|
|
700
|
+
{/* Ready: results list + detail aside. */}
|
|
701
|
+
<Show when={bodyMode() === "ready"}>
|
|
702
|
+
<div class="grid h-full min-h-0 grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_18rem]">
|
|
703
|
+
<section class="min-h-0 overflow-y-auto overscroll-y-contain pr-1" onWheel={(event) => event.stopPropagation()}>
|
|
704
|
+
<div class="flex flex-col">
|
|
705
|
+
<For each={listEntries()}>
|
|
706
|
+
{(entry) => {
|
|
707
|
+
if (entry.kind === "header") {
|
|
708
|
+
// Faint section header — no <hr>, no border. First
|
|
709
|
+
// header gets less top spacing so it doesn't shove
|
|
710
|
+
// the list down on group transitions.
|
|
711
|
+
return (
|
|
712
|
+
<div class="flex items-center gap-1.5 px-1 pt-3 pb-1 text-[10px] uppercase tracking-wide text-dimmed first:pt-1">
|
|
713
|
+
<i class={`${entry.appIcon} text-[11px]`} />
|
|
714
|
+
<span>{entry.appName}</span>
|
|
715
|
+
<span class="opacity-60">· {entry.count}</span>
|
|
716
|
+
</div>
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
const item = entry.item;
|
|
720
|
+
const selected = () => entry.flatIndex === activeIndex();
|
|
721
|
+
return (
|
|
722
|
+
<button
|
|
723
|
+
ref={(element) => bindRowRef(rowKey(item), element)}
|
|
724
|
+
type="button"
|
|
725
|
+
onMouseEnter={() => setActiveIndex(entry.flatIndex)}
|
|
726
|
+
onClick={() => openItem(item)}
|
|
727
|
+
class="mt-1.5 w-full rounded-xl p-2.5 text-left transition-colors first:mt-0"
|
|
728
|
+
classList={{
|
|
729
|
+
"bg-blue-50/85 dark:bg-blue-950/45": selected(),
|
|
730
|
+
"bg-zinc-50/75 hover:bg-zinc-100/85 dark:bg-zinc-900/45 dark:hover:bg-zinc-900/65": !selected(),
|
|
731
|
+
}}
|
|
335
732
|
>
|
|
336
|
-
<
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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>
|
|
733
|
+
<div class="flex items-start gap-2.5">
|
|
734
|
+
<i class={`${item.icon ?? item.appIcon} mt-0.5 text-[13px] text-dimmed`} />
|
|
735
|
+
<div class="min-w-0">
|
|
736
|
+
<p class="truncate text-xs">{item.title}</p>
|
|
737
|
+
<Show when={item.preview}>
|
|
738
|
+
<p class="mt-0.5 truncate text-[11px] text-dimmed">{item.preview}</p>
|
|
739
|
+
</Show>
|
|
740
|
+
<p class="mt-1 text-[10px] text-dimmed">{item.appName}</p>
|
|
362
741
|
</div>
|
|
363
|
-
|
|
364
|
-
</
|
|
742
|
+
</div>
|
|
743
|
+
</button>
|
|
744
|
+
);
|
|
745
|
+
}}
|
|
746
|
+
</For>
|
|
747
|
+
</div>
|
|
748
|
+
</section>
|
|
749
|
+
|
|
750
|
+
<aside
|
|
751
|
+
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"
|
|
752
|
+
onWheel={(event) => event.stopPropagation()}
|
|
753
|
+
>
|
|
754
|
+
<Show when={activeItem()} fallback={<div class="text-xs text-dimmed">Select a result to preview details.</div>}>
|
|
755
|
+
{(item) => (
|
|
756
|
+
<div class="flex flex-col gap-4">
|
|
757
|
+
<div class="flex items-center gap-3">
|
|
758
|
+
<div class="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-900">
|
|
759
|
+
<Show
|
|
760
|
+
when={isValidImagePreviewUrl(item().previewUrl) && !previewFailed()}
|
|
761
|
+
fallback={<i class={`${item().icon ?? item().appIcon} text-lg text-dimmed`} />}
|
|
762
|
+
>
|
|
763
|
+
<img
|
|
764
|
+
src={item().previewUrl}
|
|
765
|
+
alt={item().title}
|
|
766
|
+
class="h-full w-full object-cover"
|
|
767
|
+
onError={() => setPreviewFailed(true)}
|
|
768
|
+
/>
|
|
769
|
+
</Show>
|
|
770
|
+
</div>
|
|
771
|
+
<div class="min-w-0">
|
|
772
|
+
<p class="truncate text-sm">{item().title}</p>
|
|
773
|
+
<p class="mt-0.5 truncate text-xs text-dimmed">{item().appName}</p>
|
|
365
774
|
</div>
|
|
366
775
|
</div>
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
776
|
+
|
|
777
|
+
<Show when={item().preview}>
|
|
778
|
+
<p class="text-xs leading-relaxed text-dimmed">{item().preview}</p>
|
|
779
|
+
</Show>
|
|
780
|
+
|
|
781
|
+
<Show when={metadataRows(item().metadata).length > 0}>
|
|
782
|
+
<div class="rounded-lg bg-zinc-100/65 p-2 dark:bg-zinc-900/65">
|
|
783
|
+
<div class="divide-y divide-zinc-200/80 dark:divide-zinc-800/80">
|
|
784
|
+
<For each={metadataRows(item().metadata)}>
|
|
785
|
+
{(entry) => (
|
|
786
|
+
<div class="grid grid-cols-[7rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs first:pt-0 last:pb-0">
|
|
787
|
+
<span class="truncate text-dimmed">{entry.label}</span>
|
|
788
|
+
<span class="truncate">{entry.value}</span>
|
|
789
|
+
</div>
|
|
790
|
+
)}
|
|
791
|
+
</For>
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
</Show>
|
|
795
|
+
</div>
|
|
796
|
+
)}
|
|
797
|
+
</Show>
|
|
798
|
+
</aside>
|
|
799
|
+
</div>
|
|
800
|
+
</Show>
|
|
373
801
|
</div>
|
|
374
802
|
</div>
|
|
375
803
|
</div>
|