@hiai-gg/docsmint 0.3.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/LICENSE +171 -0
- package/README.md +348 -0
- package/backend/src/lib/logger.ts +18 -0
- package/backend/src/lib/redis-factory.ts +40 -0
- package/backend/src/lib/storage-factory.ts +56 -0
- package/frontend/src/lib/components/editor/shared-document.ts +237 -0
- package/frontend/src/lib/extensions/context.ts +60 -0
- package/frontend/src/lib/extensions/doc-tabs.ts +18 -0
- package/frontend/src/lib/extensions/resolve.ts +48 -0
- package/frontend/src/lib/extensions/types.ts +202 -0
- package/frontend/src/lib/hosts/DocsmintSharedDocumentHost.svelte +65 -0
- package/frontend/src/lib/hosts/HiaiDocsDashboardHost.svelte +1007 -0
- package/frontend/src/lib/hosts/HiaiDocsExtensionProvider.svelte +20 -0
- package/frontend/src/lib/hosts/HiaiDocsSearchHost.svelte +996 -0
- package/frontend/src/lib/hosts/index.ts +25 -0
- package/frontend/src/lib/index.ts +65 -0
- package/frontend/src/lib/stores/doc-tab-registry.svelte.ts +68 -0
- package/package.json +178 -0
- package/packages/cli/src/client.ts +271 -0
- package/packages/cli/src/commands/config.ts +47 -0
- package/packages/cli/src/commands/create.ts +35 -0
- package/packages/cli/src/commands/delete.ts +37 -0
- package/packages/cli/src/commands/export.ts +36 -0
- package/packages/cli/src/commands/folders.ts +88 -0
- package/packages/cli/src/commands/history.ts +55 -0
- package/packages/cli/src/commands/list.ts +61 -0
- package/packages/cli/src/commands/read.ts +38 -0
- package/packages/cli/src/commands/restore.ts +30 -0
- package/packages/cli/src/commands/search.ts +56 -0
- package/packages/cli/src/commands/snapshot.ts +35 -0
- package/packages/cli/src/commands/update.ts +54 -0
- package/packages/cli/src/config.ts +83 -0
- package/packages/cli/src/format.ts +153 -0
- package/packages/cli/src/index.ts +73 -0
- package/packages/db/src/client.ts +20 -0
- package/packages/db/src/index.ts +5 -0
- package/packages/db/src/schema.ts +692 -0
- package/packages/db/src/with-tenant.ts +75 -0
- package/packages/mcp-server/src/client.ts +172 -0
- package/packages/mcp-server/src/index.ts +109 -0
- package/packages/mcp-server/src/tools/create-document.ts +32 -0
- package/packages/mcp-server/src/tools/create-folder.ts +24 -0
- package/packages/mcp-server/src/tools/create-snapshot.ts +30 -0
- package/packages/mcp-server/src/tools/export-document.ts +22 -0
- package/packages/mcp-server/src/tools/get-document.ts +20 -0
- package/packages/mcp-server/src/tools/list-documents.ts +42 -0
- package/packages/mcp-server/src/tools/list-folders.ts +25 -0
- package/packages/mcp-server/src/tools/search.ts +42 -0
- package/packages/mcp-server/src/tools/update-document.ts +30 -0
- package/packages/mcp-server/src/tools/version-history.ts +32 -0
- package/packages/mcp-server/src/types.ts +126 -0
- package/packages/sdk/dist/client.d.ts +187 -0
- package/packages/sdk/dist/client.js +568 -0
- package/packages/sdk/dist/index.d.ts +3 -0
- package/packages/sdk/dist/index.js +1 -0
- package/packages/sdk/dist/types.d.ts +391 -0
- package/packages/sdk/dist/types.js +8 -0
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge } from "@hiai-gg/hiai-ui/components/ui/badge";
|
|
3
|
+
import SelectRoot from "@hiai-gg/hiai-ui/components/ui/select/select.svelte";
|
|
4
|
+
import SelectContent from "@hiai-gg/hiai-ui/components/ui/select/select-content.svelte";
|
|
5
|
+
import SelectItem from "@hiai-gg/hiai-ui/components/ui/select/select-item.svelte";
|
|
6
|
+
import SelectTrigger from "@hiai-gg/hiai-ui/components/ui/select/select-trigger.svelte";
|
|
7
|
+
import SelectValue from "@hiai-gg/hiai-ui/components/ui/select/select-value.svelte";
|
|
8
|
+
import {
|
|
9
|
+
Calendar,
|
|
10
|
+
ChevronDown,
|
|
11
|
+
ChevronLeft,
|
|
12
|
+
ChevronRight,
|
|
13
|
+
FileSearch,
|
|
14
|
+
Folder,
|
|
15
|
+
FolderKanban,
|
|
16
|
+
Loader2,
|
|
17
|
+
RotateCcw,
|
|
18
|
+
Search,
|
|
19
|
+
SlidersHorizontal,
|
|
20
|
+
Tag,
|
|
21
|
+
X,
|
|
22
|
+
} from "lucide-svelte";
|
|
23
|
+
import { goto } from "$app/navigation";
|
|
24
|
+
import { type Category, listCategories } from "$lib/api/categories";
|
|
25
|
+
import { type Document, listDocuments } from "$lib/api/documents";
|
|
26
|
+
import { listFolders } from "$lib/api/folders";
|
|
27
|
+
import { getFilterOptions, type SearchResponse, search } from "$lib/api/search";
|
|
28
|
+
import DatePicker from "$lib/components/DatePicker.svelte";
|
|
29
|
+
import SearchResult from "$lib/components/SearchResult.svelte";
|
|
30
|
+
import { getFrontendExtensions } from "$lib/extensions/context";
|
|
31
|
+
import { resolveExtensions } from "$lib/extensions/resolve";
|
|
32
|
+
import type { ExtensionVisibilityContext } from "$lib/extensions/types";
|
|
33
|
+
import * as m from "$lib/paraglide/messages.js";
|
|
34
|
+
import {
|
|
35
|
+
normalizeSearchQuery,
|
|
36
|
+
shouldForceSearchResubmit,
|
|
37
|
+
} from "$lib/search/resubmit";
|
|
38
|
+
import { getSelectedTagName } from "$lib/stores/tag-store.svelte";
|
|
39
|
+
import type { Folder as FolderType } from "$lib/types.js";
|
|
40
|
+
|
|
41
|
+
const Select = {
|
|
42
|
+
Root: SelectRoot,
|
|
43
|
+
Content: SelectContent,
|
|
44
|
+
Item: SelectItem,
|
|
45
|
+
Trigger: SelectTrigger,
|
|
46
|
+
Value: SelectValue,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface HiaiDocsSearchData {
|
|
50
|
+
query?: string;
|
|
51
|
+
filters?: {
|
|
52
|
+
folder?: string;
|
|
53
|
+
tags?: string[];
|
|
54
|
+
category?: string;
|
|
55
|
+
dateFrom?: string;
|
|
56
|
+
dateTo?: string;
|
|
57
|
+
};
|
|
58
|
+
page?: number;
|
|
59
|
+
limit?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { data, extensionContext = { pathname: "/search" } } = $props<{
|
|
63
|
+
data: HiaiDocsSearchData;
|
|
64
|
+
extensionContext?: ExtensionVisibilityContext;
|
|
65
|
+
}>();
|
|
66
|
+
|
|
67
|
+
const frontendExtensions = getFrontendExtensions();
|
|
68
|
+
|
|
69
|
+
// --- State -------------------------------------------------------------------
|
|
70
|
+
let query = $state("");
|
|
71
|
+
let activeFolder = $state("");
|
|
72
|
+
let activeTags = $state<string[]>([]);
|
|
73
|
+
// activeCategoryId is the UUID of the currently selected category filter.
|
|
74
|
+
// Empty string means "no category filter" (the default).
|
|
75
|
+
let activeCategoryId = $state("");
|
|
76
|
+
let dateFrom = $state("");
|
|
77
|
+
let dateTo = $state("");
|
|
78
|
+
let currentPage = $state(1);
|
|
79
|
+
let pageSize = $state(5);
|
|
80
|
+
let sortOrder = $state<
|
|
81
|
+
"relevance" | "date_desc" | "date_asc" | "name_asc" | "name_desc"
|
|
82
|
+
>("relevance");
|
|
83
|
+
|
|
84
|
+
// Collapsible filters state
|
|
85
|
+
let foldersExpanded = $state(true);
|
|
86
|
+
let categoriesExpanded = $state(true);
|
|
87
|
+
|
|
88
|
+
// Flat data for local search
|
|
89
|
+
let allDocuments = $state<Document[]>([]);
|
|
90
|
+
let allFolders = $state<FolderType[]>([]);
|
|
91
|
+
let allCategories = $state<Category[]>([]);
|
|
92
|
+
|
|
93
|
+
$effect(() => {
|
|
94
|
+
query = data.query ?? "";
|
|
95
|
+
activeFolder = data.filters?.folder ?? "";
|
|
96
|
+
activeTags = data.filters?.tags ?? [];
|
|
97
|
+
activeCategoryId = data.filters?.category ?? "";
|
|
98
|
+
dateFrom = data.filters?.dateFrom ?? "";
|
|
99
|
+
dateTo = data.filters?.dateTo ?? "";
|
|
100
|
+
currentPage = data.page ?? 1;
|
|
101
|
+
pageSize = data.limit ?? 5;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
let searchResponse = $state<SearchResponse | null>(null);
|
|
105
|
+
let loading = $state(false);
|
|
106
|
+
// Search requests can overlap when the query, filters, or sort order change
|
|
107
|
+
// quickly. Only the newest request may publish results or end the loading state.
|
|
108
|
+
let searchRequestGeneration = 0;
|
|
109
|
+
// SvelteKit does not invalidate page data when goto() targets the current URL.
|
|
110
|
+
// This counter makes an explicit repeat submit a distinct search request while
|
|
111
|
+
// leaving passive reactive updates deduplicated.
|
|
112
|
+
let explicitSearchGeneration = $state(0);
|
|
113
|
+
let showFilters = $state(false);
|
|
114
|
+
|
|
115
|
+
let folders = $state<string[]>([]);
|
|
116
|
+
let tags = $state<Array<{ id: string; name: string; color: string | null }>>(
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
119
|
+
let categories = $state<Array<{ id: string; name: string }>>([]);
|
|
120
|
+
|
|
121
|
+
const searchWidgets = $derived.by(() =>
|
|
122
|
+
resolveExtensions(frontendExtensions.searchWidgets, extensionContext),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// --- Derived -----------------------------------------------------------------
|
|
126
|
+
const totalPages = $derived(
|
|
127
|
+
searchResponse ? Math.ceil(searchResponse.total / pageSize) : 0,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const hasActiveFilters = $derived(
|
|
131
|
+
activeFolder !== "" ||
|
|
132
|
+
activeTags.length > 0 ||
|
|
133
|
+
activeCategoryId !== "" ||
|
|
134
|
+
dateFrom !== "" ||
|
|
135
|
+
dateTo !== "",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// --- Effects -----------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
// Load filter options and local search lists on mount
|
|
141
|
+
$effect(() => {
|
|
142
|
+
getFilterOptions().then((opts) => {
|
|
143
|
+
folders = opts.folders;
|
|
144
|
+
tags = opts.tags;
|
|
145
|
+
categories = opts.categories;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
listDocuments({ limit: 1000 }).then((res) => {
|
|
149
|
+
allDocuments = res.items;
|
|
150
|
+
});
|
|
151
|
+
listFolders(null, true).then((res) => {
|
|
152
|
+
allFolders = res;
|
|
153
|
+
});
|
|
154
|
+
listCategories().then((res) => {
|
|
155
|
+
allCategories = res;
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const queryLower = $derived(query.trim().toLowerCase());
|
|
160
|
+
|
|
161
|
+
const matchingDocs = $derived.by(() => {
|
|
162
|
+
if (!queryLower) return [];
|
|
163
|
+
return allDocuments.filter((d) => d.title.toLowerCase().includes(queryLower));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const matchingFolders = $derived.by(() => {
|
|
167
|
+
if (!queryLower) return [];
|
|
168
|
+
return allFolders.filter((f) => f.name.toLowerCase().includes(queryLower));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const matchingCategories = $derived.by(() => {
|
|
172
|
+
if (!queryLower) return [];
|
|
173
|
+
return allCategories.filter((c) => c.name.toLowerCase().includes(queryLower));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const hasAnyLocalMatches = $derived(
|
|
177
|
+
matchingDocs.length > 0 ||
|
|
178
|
+
matchingFolders.length > 0 ||
|
|
179
|
+
matchingCategories.length > 0,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Run search when query or filters change
|
|
183
|
+
$effect(() => {
|
|
184
|
+
const q = data.query;
|
|
185
|
+
// Deliberate reactive read: explicit submissions may repeat the current URL.
|
|
186
|
+
void explicitSearchGeneration;
|
|
187
|
+
const p = data.page;
|
|
188
|
+
const sort = sortOrder;
|
|
189
|
+
const folder = activeFolder;
|
|
190
|
+
const tags = activeTags;
|
|
191
|
+
const category = activeCategoryId;
|
|
192
|
+
const from = dateFrom;
|
|
193
|
+
const to = dateTo;
|
|
194
|
+
// Merge the shared selected tag (set from the sidebar TagList) into the
|
|
195
|
+
// search filter so a tag picked anywhere also narrows search results.
|
|
196
|
+
const sharedTag = getSelectedTagName();
|
|
197
|
+
const effectiveTags =
|
|
198
|
+
sharedTag && !tags.includes(sharedTag) ? [...tags, sharedTag] : tags;
|
|
199
|
+
|
|
200
|
+
if (!q) {
|
|
201
|
+
searchRequestGeneration += 1;
|
|
202
|
+
searchResponse = null;
|
|
203
|
+
loading = false;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const requestGeneration = ++searchRequestGeneration;
|
|
208
|
+
// Never leave the previous query's empty response visible while the adaptive
|
|
209
|
+
// semantic channels are still working on the current query.
|
|
210
|
+
searchResponse = null;
|
|
211
|
+
loading = true;
|
|
212
|
+
|
|
213
|
+
search(q, p, pageSize, sort, {
|
|
214
|
+
folder: folder || undefined,
|
|
215
|
+
tags: effectiveTags.length > 0 ? effectiveTags : undefined,
|
|
216
|
+
category: category || undefined,
|
|
217
|
+
dateFrom: from || undefined,
|
|
218
|
+
dateTo: to || undefined,
|
|
219
|
+
})
|
|
220
|
+
.then((res) => {
|
|
221
|
+
if (requestGeneration !== searchRequestGeneration) return;
|
|
222
|
+
searchResponse = res;
|
|
223
|
+
loading = false;
|
|
224
|
+
})
|
|
225
|
+
.catch(() => {
|
|
226
|
+
if (requestGeneration !== searchRequestGeneration) return;
|
|
227
|
+
// Preserve the existing terminal empty-state fallback on request failure,
|
|
228
|
+
// but never let a stale failure interrupt a newer in-flight search.
|
|
229
|
+
searchResponse = { items: [], total: 0, page: p, limit: pageSize };
|
|
230
|
+
loading = false;
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// --- Helpers -----------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
function buildUrl(overrides: Record<string, string | undefined>) {
|
|
237
|
+
const params = new URLSearchParams();
|
|
238
|
+
|
|
239
|
+
const q = "q" in overrides ? overrides.q : query;
|
|
240
|
+
const folder = "folder" in overrides ? overrides.folder : activeFolder;
|
|
241
|
+
const t = "tags" in overrides ? overrides.tags : activeTags.join(",");
|
|
242
|
+
const cat = "category" in overrides ? overrides.category : activeCategoryId;
|
|
243
|
+
const df = "dateFrom" in overrides ? overrides.dateFrom : dateFrom;
|
|
244
|
+
const dt = "dateTo" in overrides ? overrides.dateTo : dateTo;
|
|
245
|
+
const p = "page" in overrides ? overrides.page : String(currentPage);
|
|
246
|
+
const l = "limit" in overrides ? overrides.limit : String(pageSize);
|
|
247
|
+
|
|
248
|
+
if (q) params.set("q", q);
|
|
249
|
+
if (folder) params.set("folder", folder);
|
|
250
|
+
if (t) params.set("tags", t);
|
|
251
|
+
if (cat) params.set("category", cat);
|
|
252
|
+
if (df) params.set("dateFrom", df);
|
|
253
|
+
if (dt) params.set("dateTo", dt);
|
|
254
|
+
if (p && p !== "1") params.set("page", p);
|
|
255
|
+
if (l && l !== "5") params.set("limit", l);
|
|
256
|
+
|
|
257
|
+
return `/search?${params.toString()}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function handleSubmit(e: SubmitEvent) {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
const submittedQuery = normalizeSearchQuery(query);
|
|
263
|
+
if (!submittedQuery) {
|
|
264
|
+
clearSearch();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
query = submittedQuery;
|
|
269
|
+
if (
|
|
270
|
+
shouldForceSearchResubmit({
|
|
271
|
+
submittedQuery,
|
|
272
|
+
loadedQuery: data.query,
|
|
273
|
+
currentPage,
|
|
274
|
+
})
|
|
275
|
+
) {
|
|
276
|
+
currentPage = 1;
|
|
277
|
+
explicitSearchGeneration += 1;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
currentPage = 1;
|
|
282
|
+
goto(buildUrl({ q: submittedQuery, page: "1" }), { replaceState: true });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
286
|
+
if (e.key === "Escape") {
|
|
287
|
+
if (showFilters) {
|
|
288
|
+
showFilters = false;
|
|
289
|
+
} else if (query || hasActiveFilters) {
|
|
290
|
+
clearSearch();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function clearSearch() {
|
|
296
|
+
query = "";
|
|
297
|
+
activeFolder = "";
|
|
298
|
+
activeTags = [];
|
|
299
|
+
activeCategoryId = "";
|
|
300
|
+
dateFrom = "";
|
|
301
|
+
dateTo = "";
|
|
302
|
+
currentPage = 1;
|
|
303
|
+
goto("/search", { replaceState: true });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function toggleFolder(folder: string) {
|
|
307
|
+
activeFolder = activeFolder === folder ? "" : folder;
|
|
308
|
+
currentPage = 1;
|
|
309
|
+
goto(buildUrl({ folder: activeFolder, page: "1" }), { replaceState: true });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function toggleTag(tag: string) {
|
|
313
|
+
activeTags = activeTags.includes(tag)
|
|
314
|
+
? activeTags.filter((t) => t !== tag)
|
|
315
|
+
: [...activeTags, tag];
|
|
316
|
+
currentPage = 1;
|
|
317
|
+
goto(buildUrl({ tags: activeTags.join(","), page: "1" }), {
|
|
318
|
+
replaceState: true,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function toggleCategory(categoryId: string) {
|
|
323
|
+
activeCategoryId = activeCategoryId === categoryId ? "" : categoryId;
|
|
324
|
+
currentPage = 1;
|
|
325
|
+
goto(buildUrl({ category: activeCategoryId, page: "1" }), {
|
|
326
|
+
replaceState: true,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function applyDateRange() {
|
|
331
|
+
currentPage = 1;
|
|
332
|
+
goto(buildUrl({ dateFrom, dateTo, page: "1" }), { replaceState: true });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function clearFilters() {
|
|
336
|
+
activeFolder = "";
|
|
337
|
+
activeTags = [];
|
|
338
|
+
activeCategoryId = "";
|
|
339
|
+
dateFrom = "";
|
|
340
|
+
dateTo = "";
|
|
341
|
+
currentPage = 1;
|
|
342
|
+
goto(
|
|
343
|
+
buildUrl({
|
|
344
|
+
folder: undefined,
|
|
345
|
+
tags: undefined,
|
|
346
|
+
category: undefined,
|
|
347
|
+
dateFrom: undefined,
|
|
348
|
+
dateTo: undefined,
|
|
349
|
+
page: "1",
|
|
350
|
+
}),
|
|
351
|
+
{
|
|
352
|
+
replaceState: true,
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function goToPage(page: number) {
|
|
358
|
+
currentPage = page;
|
|
359
|
+
goto(buildUrl({ page: String(page) }), { replaceState: true });
|
|
360
|
+
}
|
|
361
|
+
</script>
|
|
362
|
+
|
|
363
|
+
<svelte:window onkeydown={handleKeydown} />
|
|
364
|
+
|
|
365
|
+
<svelte:head>
|
|
366
|
+
<title>{data.query ? m.search_title_with_query({query: data.query}) : m.search_title()} - {m.app_name()}</title>
|
|
367
|
+
</svelte:head>
|
|
368
|
+
|
|
369
|
+
<div class="search-shell mx-auto max-w-6xl px-4 py-8">
|
|
370
|
+
<!-- Search input -->
|
|
371
|
+
<form onsubmit={handleSubmit} class="search-form relative mb-6">
|
|
372
|
+
<Search
|
|
373
|
+
class="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
|
|
374
|
+
/>
|
|
375
|
+
<input
|
|
376
|
+
type="text"
|
|
377
|
+
bind:value={query}
|
|
378
|
+
placeholder={m.search_input_placeholder()}
|
|
379
|
+
class="h-14 w-full rounded-xl border border-input bg-background pl-12 pr-24 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
380
|
+
/>
|
|
381
|
+
<div class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
|
382
|
+
{#if query}
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
onclick={clearSearch}
|
|
386
|
+
class="rounded-md p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
387
|
+
aria-label={m.search_clear()}
|
|
388
|
+
>
|
|
389
|
+
<X class="h-4 w-4" />
|
|
390
|
+
</button>
|
|
391
|
+
{/if}
|
|
392
|
+
<button
|
|
393
|
+
type="button"
|
|
394
|
+
onclick={() => (showFilters = !showFilters)}
|
|
395
|
+
class="rounded-md p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
|
|
396
|
+
aria-label={m.search_toggle_filters()}
|
|
397
|
+
>
|
|
398
|
+
<SlidersHorizontal class="h-4 w-4" />
|
|
399
|
+
</button>
|
|
400
|
+
</div>
|
|
401
|
+
</form>
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
<div class="flex gap-8">
|
|
406
|
+
<!-- Filter sidebar -->
|
|
407
|
+
<aside
|
|
408
|
+
class="hidden lg:block w-56 shrink-0 space-y-6"
|
|
409
|
+
>
|
|
410
|
+
{@render filterPanel()}
|
|
411
|
+
</aside>
|
|
412
|
+
|
|
413
|
+
<!-- Mobile filter sheet -->
|
|
414
|
+
{#if showFilters}
|
|
415
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
416
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
417
|
+
<div
|
|
418
|
+
class="fixed inset-0 z-50 bg-black/50 lg:hidden"
|
|
419
|
+
onclick={() => (showFilters = false)}
|
|
420
|
+
>
|
|
421
|
+
<div
|
|
422
|
+
class="absolute right-0 top-0 h-full w-72 overflow-y-auto bg-background p-6 shadow-xl"
|
|
423
|
+
onclick={(e) => e.stopPropagation()}
|
|
424
|
+
>
|
|
425
|
+
<div class="mb-4 flex items-center justify-between">
|
|
426
|
+
<h3 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
427
|
+
{m.search_filters()}
|
|
428
|
+
</h3>
|
|
429
|
+
<button
|
|
430
|
+
onclick={() => (showFilters = false)}
|
|
431
|
+
class="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
|
432
|
+
aria-label={m.search_close_filters()}
|
|
433
|
+
>
|
|
434
|
+
<X class="h-4 w-4" />
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
{@render filterPanel()}
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
{/if}
|
|
441
|
+
|
|
442
|
+
<!-- Results area -->
|
|
443
|
+
<main class="min-w-0 flex-1">
|
|
444
|
+
<!-- Instant Title Matches -->
|
|
445
|
+
{#if query.trim() && hasAnyLocalMatches}
|
|
446
|
+
<div class="mb-6 space-y-4">
|
|
447
|
+
<h3 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
448
|
+
Instant Title Matches
|
|
449
|
+
</h3>
|
|
450
|
+
|
|
451
|
+
{#if matchingDocs.length > 0}
|
|
452
|
+
<div class="space-y-2">
|
|
453
|
+
<h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/80">
|
|
454
|
+
{m.nav_documents()}
|
|
455
|
+
</h4>
|
|
456
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
457
|
+
{#each matchingDocs as doc (doc.id)}
|
|
458
|
+
<a
|
|
459
|
+
href="/docs/{doc.id}"
|
|
460
|
+
onclick={(e) => {
|
|
461
|
+
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
goto(`/docs/${doc.id}`);
|
|
464
|
+
}
|
|
465
|
+
}}
|
|
466
|
+
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-accent"
|
|
467
|
+
>
|
|
468
|
+
<FileSearch class="size-4 shrink-0 text-muted-foreground" />
|
|
469
|
+
<span class="font-medium text-sm truncate">{doc.title}</span>
|
|
470
|
+
</a>
|
|
471
|
+
{/each}
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
{/if}
|
|
475
|
+
|
|
476
|
+
{#if matchingFolders.length > 0}
|
|
477
|
+
<div class="space-y-2">
|
|
478
|
+
<h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/80">
|
|
479
|
+
{m.nav_folders()}
|
|
480
|
+
</h4>
|
|
481
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
482
|
+
{#each matchingFolders as folder (folder.id)}
|
|
483
|
+
<a
|
|
484
|
+
href="/folders/{folder.id}"
|
|
485
|
+
onclick={(e) => {
|
|
486
|
+
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
|
|
487
|
+
e.preventDefault();
|
|
488
|
+
goto(`/folders/${folder.id}`);
|
|
489
|
+
}
|
|
490
|
+
}}
|
|
491
|
+
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-accent"
|
|
492
|
+
>
|
|
493
|
+
<Folder class="size-4 shrink-0 text-muted-foreground" />
|
|
494
|
+
<span class="font-medium text-sm truncate">{folder.name}</span>
|
|
495
|
+
</a>
|
|
496
|
+
{/each}
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
{/if}
|
|
500
|
+
|
|
501
|
+
{#if matchingCategories.length > 0}
|
|
502
|
+
<div class="space-y-2">
|
|
503
|
+
<h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/80">
|
|
504
|
+
{m.categories_title()}
|
|
505
|
+
</h4>
|
|
506
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
507
|
+
{#each matchingCategories as category (category.id)}
|
|
508
|
+
<a
|
|
509
|
+
href="/folders#category-{category.id}"
|
|
510
|
+
onclick={(e) => {
|
|
511
|
+
if (e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
|
|
512
|
+
e.preventDefault();
|
|
513
|
+
goto(`/folders#category-${category.id}`);
|
|
514
|
+
}
|
|
515
|
+
}}
|
|
516
|
+
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-accent"
|
|
517
|
+
>
|
|
518
|
+
<FolderKanban class="size-4 shrink-0 text-muted-foreground" />
|
|
519
|
+
<span class="font-medium text-sm truncate">{category.name}</span>
|
|
520
|
+
</a>
|
|
521
|
+
{/each}
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
{/if}
|
|
525
|
+
</div>
|
|
526
|
+
{/if}
|
|
527
|
+
|
|
528
|
+
<!-- Server Search Results -->
|
|
529
|
+
{#if query.trim()}
|
|
530
|
+
<div class="border-t border-border/60 pt-6">
|
|
531
|
+
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
532
|
+
AI-powered Semantic Search
|
|
533
|
+
</h3>
|
|
534
|
+
{#if searchWidgets.length > 0}
|
|
535
|
+
<section
|
|
536
|
+
class="mb-4 space-y-3"
|
|
537
|
+
data-hiai-docs-extension-zone="search-widgets"
|
|
538
|
+
aria-label="Search extensions"
|
|
539
|
+
>
|
|
540
|
+
{#each searchWidgets as widget (widget.id)}
|
|
541
|
+
{@const Widget = widget.component}
|
|
542
|
+
<div data-hiai-docs-extension-id={widget.id}>
|
|
543
|
+
{#if widget.title}
|
|
544
|
+
<h4 class="mb-2 text-sm font-medium">{widget.title}</h4>
|
|
545
|
+
{/if}
|
|
546
|
+
<Widget
|
|
547
|
+
query={query}
|
|
548
|
+
{loading}
|
|
549
|
+
total={searchResponse?.total}
|
|
550
|
+
/>
|
|
551
|
+
</div>
|
|
552
|
+
{/each}
|
|
553
|
+
</section>
|
|
554
|
+
{/if}
|
|
555
|
+
{#if loading}
|
|
556
|
+
{@render loadingState()}
|
|
557
|
+
{:else if searchResponse && searchResponse.items.length > 0}
|
|
558
|
+
{@render resultsList()}
|
|
559
|
+
{:else if !hasAnyLocalMatches}
|
|
560
|
+
{@render noResults()}
|
|
561
|
+
{/if}
|
|
562
|
+
</div>
|
|
563
|
+
{:else}
|
|
564
|
+
{@render emptyState()}
|
|
565
|
+
{/if}
|
|
566
|
+
</main>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
<!-- Filter panel content (reused for sidebar + mobile sheet) -->
|
|
571
|
+
{#snippet filterPanel()}
|
|
572
|
+
<div class="space-y-6">
|
|
573
|
+
<!-- Active filter summary -->
|
|
574
|
+
{#if hasActiveFilters}
|
|
575
|
+
<button
|
|
576
|
+
onclick={clearFilters}
|
|
577
|
+
class="inline-flex w-full items-center justify-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
578
|
+
>
|
|
579
|
+
<RotateCcw class="size-3" />
|
|
580
|
+
{m.search_clear_all_filters()}
|
|
581
|
+
</button>
|
|
582
|
+
{/if}
|
|
583
|
+
|
|
584
|
+
<!-- Folder filter -->
|
|
585
|
+
{#if folders.length > 0}
|
|
586
|
+
<div>
|
|
587
|
+
<button
|
|
588
|
+
type="button"
|
|
589
|
+
onclick={() => (foldersExpanded = !foldersExpanded)}
|
|
590
|
+
class="mb-2 flex w-full items-center justify-between text-xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
|
|
591
|
+
>
|
|
592
|
+
<span class="flex items-center gap-1.5">
|
|
593
|
+
<Folder class="size-3.5" />
|
|
594
|
+
{m.search_folders()}
|
|
595
|
+
</span>
|
|
596
|
+
{#if foldersExpanded}
|
|
597
|
+
<ChevronDown class="size-3" />
|
|
598
|
+
{:else}
|
|
599
|
+
<ChevronRight class="size-3" />
|
|
600
|
+
{/if}
|
|
601
|
+
</button>
|
|
602
|
+
{#if foldersExpanded}
|
|
603
|
+
<div class="space-y-0.5">
|
|
604
|
+
{#each folders as folder}
|
|
605
|
+
<button
|
|
606
|
+
onclick={() => toggleFolder(folder)}
|
|
607
|
+
class="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-sm transition-colors {activeFolder === folder
|
|
608
|
+
? 'bg-primary/10 font-medium text-primary'
|
|
609
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
|
610
|
+
>
|
|
611
|
+
<span class="truncate">{folder}</span>
|
|
612
|
+
{#if activeFolder === folder}
|
|
613
|
+
<X class="size-3 shrink-0" />
|
|
614
|
+
{/if}
|
|
615
|
+
</button>
|
|
616
|
+
{/each}
|
|
617
|
+
</div>
|
|
618
|
+
{/if}
|
|
619
|
+
</div>
|
|
620
|
+
{/if}
|
|
621
|
+
|
|
622
|
+
<!-- Tag filter -->
|
|
623
|
+
{#if tags.length > 0}
|
|
624
|
+
<div>
|
|
625
|
+
<h4
|
|
626
|
+
class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
|
627
|
+
>
|
|
628
|
+
<Tag class="size-3.5" />
|
|
629
|
+
{m.search_tags()}
|
|
630
|
+
</h4>
|
|
631
|
+
<div class="flex flex-wrap gap-1.5">
|
|
632
|
+
{#each tags as tag (tag.id)}
|
|
633
|
+
<button
|
|
634
|
+
onclick={() => toggleTag(tag.name)}
|
|
635
|
+
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium transition-colors {activeTags.includes(tag.name)
|
|
636
|
+
? 'bg-primary text-primary-foreground'
|
|
637
|
+
: 'bg-secondary text-secondary-foreground hover:bg-secondary/80'}"
|
|
638
|
+
>
|
|
639
|
+
{#if tag.color}
|
|
640
|
+
<span class="mr-1.5 inline-block size-2 rounded-full" style="background-color: {tag.color}"></span>
|
|
641
|
+
{/if}
|
|
642
|
+
{tag.name}
|
|
643
|
+
</button>
|
|
644
|
+
{/each}
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
{/if}
|
|
648
|
+
|
|
649
|
+
<!-- Category filter -->
|
|
650
|
+
{#if categories.length > 0}
|
|
651
|
+
<div>
|
|
652
|
+
<button
|
|
653
|
+
type="button"
|
|
654
|
+
onclick={() => (categoriesExpanded = !categoriesExpanded)}
|
|
655
|
+
class="mb-2 flex w-full items-center justify-between text-xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
|
|
656
|
+
>
|
|
657
|
+
<span class="flex items-center gap-1.5">
|
|
658
|
+
<FolderKanban class="size-3.5" />
|
|
659
|
+
{m.categories_title()}
|
|
660
|
+
</span>
|
|
661
|
+
{#if categoriesExpanded}
|
|
662
|
+
<ChevronDown class="size-3" />
|
|
663
|
+
{:else}
|
|
664
|
+
<ChevronRight class="size-3" />
|
|
665
|
+
{/if}
|
|
666
|
+
</button>
|
|
667
|
+
{#if categoriesExpanded}
|
|
668
|
+
<div class="space-y-0.5">
|
|
669
|
+
{#each categories as category (category.id)}
|
|
670
|
+
<button
|
|
671
|
+
onclick={() => toggleCategory(category.id)}
|
|
672
|
+
class="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-sm transition-colors {activeCategoryId === category.id
|
|
673
|
+
? 'bg-primary/10 font-medium text-primary'
|
|
674
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
|
675
|
+
>
|
|
676
|
+
<span class="truncate">{category.name}</span>
|
|
677
|
+
{#if activeCategoryId === category.id}
|
|
678
|
+
<X class="size-3 shrink-0" />
|
|
679
|
+
{/if}
|
|
680
|
+
</button>
|
|
681
|
+
{/each}
|
|
682
|
+
</div>
|
|
683
|
+
{/if}
|
|
684
|
+
</div>
|
|
685
|
+
{/if}
|
|
686
|
+
|
|
687
|
+
<!-- Date range filter -->
|
|
688
|
+
<div>
|
|
689
|
+
<h4
|
|
690
|
+
class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
|
691
|
+
>
|
|
692
|
+
<Calendar class="size-3.5" />
|
|
693
|
+
{m.search_date_range()}
|
|
694
|
+
</h4>
|
|
695
|
+
<div class="space-y-2">
|
|
696
|
+
<div>
|
|
697
|
+
<label for="dateFrom" class="text-xs text-muted-foreground">{m.search_date_from()}</label>
|
|
698
|
+
<div class="mt-0.5">
|
|
699
|
+
<DatePicker
|
|
700
|
+
id="dateFrom"
|
|
701
|
+
bind:value={dateFrom}
|
|
702
|
+
onchange={applyDateRange}
|
|
703
|
+
ariaLabel={m.search_date_from()}
|
|
704
|
+
placeholder={m.search_date_from()}
|
|
705
|
+
/>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
<div>
|
|
709
|
+
<label for="dateTo" class="text-xs text-muted-foreground">{m.search_date_to()}</label>
|
|
710
|
+
<div class="mt-0.5">
|
|
711
|
+
<DatePicker
|
|
712
|
+
id="dateTo"
|
|
713
|
+
bind:value={dateTo}
|
|
714
|
+
onchange={applyDateRange}
|
|
715
|
+
ariaLabel={m.search_date_to()}
|
|
716
|
+
placeholder={m.search_date_to()}
|
|
717
|
+
/>
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
{/snippet}
|
|
724
|
+
|
|
725
|
+
<style>
|
|
726
|
+
@media (max-width: 767px) {
|
|
727
|
+
.search-form {
|
|
728
|
+
margin-left: 56px;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
@media (max-width: 420px) {
|
|
733
|
+
.search-form {
|
|
734
|
+
margin-left: 52px;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
</style>
|
|
738
|
+
|
|
739
|
+
<!-- Loading state -->
|
|
740
|
+
{#snippet loadingState()}
|
|
741
|
+
<div class="space-y-4" aria-live="polite" aria-busy="true">
|
|
742
|
+
<div class="relative overflow-hidden rounded-xl border border-primary/20 bg-gradient-to-r from-primary/10 via-card to-primary/5 px-5 py-4">
|
|
743
|
+
<div class="absolute inset-y-0 left-0 w-1 bg-primary"></div>
|
|
744
|
+
<div class="flex items-center gap-3">
|
|
745
|
+
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
746
|
+
<Loader2 class="size-5 animate-spin" />
|
|
747
|
+
</div>
|
|
748
|
+
<div>
|
|
749
|
+
<p class="font-medium text-foreground">{m.search_semantic_search_in_progress()}</p>
|
|
750
|
+
<p class="mt-0.5 text-sm text-muted-foreground">{m.search_semantic_search_detail()}</p>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
</div>
|
|
754
|
+
{#each Array(3) as _, i}
|
|
755
|
+
<div class="animate-pulse rounded-lg border border-border bg-card p-5">
|
|
756
|
+
<div class="flex items-start justify-between gap-3">
|
|
757
|
+
<div class="h-5 w-2/3 rounded bg-muted"></div>
|
|
758
|
+
<div class="h-5 w-12 rounded-full bg-muted"></div>
|
|
759
|
+
</div>
|
|
760
|
+
<div class="mt-3 space-y-2">
|
|
761
|
+
<div class="h-4 w-full rounded bg-muted"></div>
|
|
762
|
+
<div class="h-4 w-5/6 rounded bg-muted"></div>
|
|
763
|
+
</div>
|
|
764
|
+
<div class="mt-3 flex gap-4">
|
|
765
|
+
<div class="h-3.5 w-20 rounded bg-muted"></div>
|
|
766
|
+
<div class="h-3.5 w-28 rounded bg-muted"></div>
|
|
767
|
+
<div class="h-3.5 w-24 rounded bg-muted"></div>
|
|
768
|
+
</div>
|
|
769
|
+
</div>
|
|
770
|
+
{/each}
|
|
771
|
+
</div>
|
|
772
|
+
{/snippet}
|
|
773
|
+
|
|
774
|
+
<!-- Results list -->
|
|
775
|
+
{#snippet resultsList()}
|
|
776
|
+
<div>
|
|
777
|
+
<div class="mb-4 flex items-center justify-between gap-3">
|
|
778
|
+
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
|
779
|
+
<p>
|
|
780
|
+
{(searchResponse?.total ?? 0) === 1 ? m.search_result_for({count: searchResponse!.total}) : m.search_results_for({count: searchResponse!.total})}
|
|
781
|
+
"<span class="font-medium text-foreground">{data.query}</span>"
|
|
782
|
+
</p>
|
|
783
|
+
</div>
|
|
784
|
+
<div class="flex items-center gap-4">
|
|
785
|
+
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
786
|
+
<span>Results per page:</span>
|
|
787
|
+
<Select.Root
|
|
788
|
+
type="single"
|
|
789
|
+
value={String(pageSize)}
|
|
790
|
+
onValueChange={(val: string) => {
|
|
791
|
+
if (val) {
|
|
792
|
+
const numVal = Number(val);
|
|
793
|
+
pageSize = numVal;
|
|
794
|
+
currentPage = 1;
|
|
795
|
+
goto(buildUrl({ page: "1", limit: val }), { replaceState: true });
|
|
796
|
+
}
|
|
797
|
+
}}
|
|
798
|
+
>
|
|
799
|
+
<Select.Trigger class="h-9 w-[70px] text-foreground flex items-center justify-between bg-background border border-input px-3 py-2 text-sm rounded-md shadow-sm">
|
|
800
|
+
<Select.Value placeholder={String(pageSize)}>
|
|
801
|
+
{pageSize}
|
|
802
|
+
</Select.Value>
|
|
803
|
+
</Select.Trigger>
|
|
804
|
+
<Select.Content class="w-[70px]">
|
|
805
|
+
<Select.Item value="5">5</Select.Item>
|
|
806
|
+
<Select.Item value="10">10</Select.Item>
|
|
807
|
+
<Select.Item value="20">20</Select.Item>
|
|
808
|
+
<Select.Item value="50">50</Select.Item>
|
|
809
|
+
</Select.Content>
|
|
810
|
+
</Select.Root>
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
<Select.Root
|
|
814
|
+
type="single"
|
|
815
|
+
value={sortOrder}
|
|
816
|
+
onValueChange={(val: string) => {
|
|
817
|
+
if (val) {
|
|
818
|
+
const prev = sortOrder;
|
|
819
|
+
sortOrder = val as any;
|
|
820
|
+
if (prev !== val) {
|
|
821
|
+
currentPage = 1;
|
|
822
|
+
goto(buildUrl({ page: "1" }), { replaceState: true });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}}
|
|
826
|
+
>
|
|
827
|
+
<Select.Trigger class="h-9 w-[180px] text-foreground flex items-center justify-between bg-background border border-input px-3 py-2 text-sm rounded-md shadow-sm">
|
|
828
|
+
<Select.Value placeholder={m.sort_relevance()}>
|
|
829
|
+
{#if sortOrder === "relevance"}
|
|
830
|
+
{m.sort_relevance()}
|
|
831
|
+
{:else if sortOrder === "date_desc"}
|
|
832
|
+
{m.sort_date_newest()}
|
|
833
|
+
{:else if sortOrder === "date_asc"}
|
|
834
|
+
{m.sort_date_oldest()}
|
|
835
|
+
{:else if sortOrder === "name_asc"}
|
|
836
|
+
{m.sort_name_asc()}
|
|
837
|
+
{:else if sortOrder === "name_desc"}
|
|
838
|
+
{m.sort_name_desc()}
|
|
839
|
+
{/if}
|
|
840
|
+
</Select.Value>
|
|
841
|
+
</Select.Trigger>
|
|
842
|
+
<Select.Content class="w-[180px]">
|
|
843
|
+
<Select.Item value="relevance">{m.sort_relevance()}</Select.Item>
|
|
844
|
+
<Select.Item value="date_desc">{m.sort_date_newest()}</Select.Item>
|
|
845
|
+
<Select.Item value="date_asc">{m.sort_date_oldest()}</Select.Item>
|
|
846
|
+
<Select.Item value="name_asc">{m.sort_name_asc()}</Select.Item>
|
|
847
|
+
<Select.Item value="name_desc">{m.sort_name_desc()}</Select.Item>
|
|
848
|
+
</Select.Content>
|
|
849
|
+
</Select.Root>
|
|
850
|
+
</div>
|
|
851
|
+
</div>
|
|
852
|
+
|
|
853
|
+
<div class="space-y-3">
|
|
854
|
+
{#each searchResponse?.items ?? [] as result (result.id)}
|
|
855
|
+
<SearchResult
|
|
856
|
+
id={result.id}
|
|
857
|
+
title={result.title}
|
|
858
|
+
snippet={result.snippet}
|
|
859
|
+
score={result.score}
|
|
860
|
+
folderName={result.folder_name ?? ""}
|
|
861
|
+
tags={result.tags ?? []}
|
|
862
|
+
createdAt={result.created_at}
|
|
863
|
+
query={data.query}
|
|
864
|
+
explanations={result.explanations}
|
|
865
|
+
chunks={result.chunks}
|
|
866
|
+
/>
|
|
867
|
+
{/each}
|
|
868
|
+
</div>
|
|
869
|
+
|
|
870
|
+
<!-- Pagination -->
|
|
871
|
+
{#if totalPages > 1}
|
|
872
|
+
<nav class="mt-8 flex items-center justify-center gap-1.5" aria-label={m.search_pages_aria()}>
|
|
873
|
+
<button
|
|
874
|
+
onclick={() => goToPage(currentPage - 1)}
|
|
875
|
+
disabled={currentPage <= 1}
|
|
876
|
+
aria-label={m.search_previous_page()}
|
|
877
|
+
class="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-2.5 text-sm font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
878
|
+
>
|
|
879
|
+
<ChevronLeft class="h-4 w-4" />
|
|
880
|
+
</button>
|
|
881
|
+
|
|
882
|
+
{#each Array(totalPages) as _, i}
|
|
883
|
+
{@const pageNum = i + 1}
|
|
884
|
+
{#if totalPages <= 7 || pageNum === 1 || pageNum === totalPages || (pageNum >= currentPage - 1 && pageNum <= currentPage + 1)}
|
|
885
|
+
<button
|
|
886
|
+
onclick={() => goToPage(pageNum)}
|
|
887
|
+
aria-label={m.search_page_number({num: pageNum})}
|
|
888
|
+
aria-current={pageNum === currentPage ? "page" : undefined}
|
|
889
|
+
class="inline-flex h-9 min-w-9 items-center justify-center rounded-md px-2.5 text-sm font-medium shadow-sm transition-colors {pageNum === currentPage
|
|
890
|
+
? 'bg-primary text-primary-foreground'
|
|
891
|
+
: 'border border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
|
892
|
+
>
|
|
893
|
+
{pageNum}
|
|
894
|
+
</button>
|
|
895
|
+
{:else if pageNum === 2 || pageNum === totalPages - 1}
|
|
896
|
+
<span class="px-1 text-muted-foreground">...</span>
|
|
897
|
+
{/if}
|
|
898
|
+
{/each}
|
|
899
|
+
|
|
900
|
+
<button
|
|
901
|
+
onclick={() => goToPage(currentPage + 1)}
|
|
902
|
+
disabled={currentPage >= totalPages}
|
|
903
|
+
aria-label={m.search_next_page()}
|
|
904
|
+
class="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-2.5 text-sm font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
905
|
+
>
|
|
906
|
+
<ChevronRight class="h-4 w-4" />
|
|
907
|
+
</button>
|
|
908
|
+
</nav>
|
|
909
|
+
{/if}
|
|
910
|
+
</div>
|
|
911
|
+
{/snippet}
|
|
912
|
+
|
|
913
|
+
<!-- No results -->
|
|
914
|
+
{#snippet noResults()}
|
|
915
|
+
<div class="flex flex-col items-center justify-center py-20 text-center">
|
|
916
|
+
<FileSearch class="mb-4 h-16 w-16 text-muted-foreground/40" />
|
|
917
|
+
<h2 class="text-xl font-semibold text-foreground">{m.search_no_results()}</h2>
|
|
918
|
+
<p class="mt-2 max-w-sm text-sm text-muted-foreground">
|
|
919
|
+
{m.search_no_results_query({query: data.query ?? ""})}
|
|
920
|
+
{m.search_no_results_tip()}
|
|
921
|
+
</p>
|
|
922
|
+
<div class="mt-6 flex gap-3">
|
|
923
|
+
{#if hasActiveFilters}
|
|
924
|
+
<button
|
|
925
|
+
onclick={clearFilters}
|
|
926
|
+
class="inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-input bg-background px-4 text-sm font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
|
927
|
+
>
|
|
928
|
+
<RotateCcw class="size-3.5" />
|
|
929
|
+
{m.search_clear_filters()}
|
|
930
|
+
</button>
|
|
931
|
+
{/if}
|
|
932
|
+
<button
|
|
933
|
+
onclick={clearSearch}
|
|
934
|
+
class="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
|
935
|
+
>
|
|
936
|
+
{m.search_new_search()}
|
|
937
|
+
</button>
|
|
938
|
+
</div>
|
|
939
|
+
|
|
940
|
+
<!-- Search suggestions -->
|
|
941
|
+
<div class="mt-10">
|
|
942
|
+
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
943
|
+
{m.search_try_searching()}
|
|
944
|
+
</p>
|
|
945
|
+
<div class="flex flex-wrap justify-center gap-2">
|
|
946
|
+
{#each ["getting started", "docker deployment", "API reference", "authentication", "embeddings"] as suggestion}
|
|
947
|
+
<button
|
|
948
|
+
onclick={() => {
|
|
949
|
+
query = suggestion;
|
|
950
|
+
goto(buildUrl({ q: suggestion, page: "1" }), {
|
|
951
|
+
replaceState: true,
|
|
952
|
+
});
|
|
953
|
+
}}
|
|
954
|
+
class="rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-muted hover:text-foreground"
|
|
955
|
+
>
|
|
956
|
+
{suggestion}
|
|
957
|
+
</button>
|
|
958
|
+
{/each}
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
</div>
|
|
962
|
+
{/snippet}
|
|
963
|
+
|
|
964
|
+
<!-- Empty state (no query yet) -->
|
|
965
|
+
{#snippet emptyState()}
|
|
966
|
+
<div class="flex flex-col items-center justify-center py-20 text-center">
|
|
967
|
+
<Search class="mb-4 h-16 w-16 text-muted-foreground/40" />
|
|
968
|
+
<h2 class="text-xl font-semibold text-foreground">
|
|
969
|
+
{m.search_empty_title()}
|
|
970
|
+
</h2>
|
|
971
|
+
<p class="mt-2 max-w-sm text-sm text-muted-foreground">
|
|
972
|
+
{m.search_empty_description()}
|
|
973
|
+
</p>
|
|
974
|
+
|
|
975
|
+
<div class="mt-8">
|
|
976
|
+
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
977
|
+
{m.search_popular()}
|
|
978
|
+
</p>
|
|
979
|
+
<div class="flex flex-wrap justify-center gap-2">
|
|
980
|
+
{#each ["getting started", "docker deployment", "API reference", "authentication", "embeddings", "Svelte 5"] as suggestion}
|
|
981
|
+
<button
|
|
982
|
+
onclick={() => {
|
|
983
|
+
query = suggestion;
|
|
984
|
+
goto(buildUrl({ q: suggestion, page: "1" }), {
|
|
985
|
+
replaceState: true,
|
|
986
|
+
});
|
|
987
|
+
}}
|
|
988
|
+
class="rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/30 hover:bg-muted hover:text-foreground"
|
|
989
|
+
>
|
|
990
|
+
{suggestion}
|
|
991
|
+
</button>
|
|
992
|
+
{/each}
|
|
993
|
+
</div>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
{/snippet}
|