@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.
Files changed (57) hide show
  1. package/LICENSE +171 -0
  2. package/README.md +348 -0
  3. package/backend/src/lib/logger.ts +18 -0
  4. package/backend/src/lib/redis-factory.ts +40 -0
  5. package/backend/src/lib/storage-factory.ts +56 -0
  6. package/frontend/src/lib/components/editor/shared-document.ts +237 -0
  7. package/frontend/src/lib/extensions/context.ts +60 -0
  8. package/frontend/src/lib/extensions/doc-tabs.ts +18 -0
  9. package/frontend/src/lib/extensions/resolve.ts +48 -0
  10. package/frontend/src/lib/extensions/types.ts +202 -0
  11. package/frontend/src/lib/hosts/DocsmintSharedDocumentHost.svelte +65 -0
  12. package/frontend/src/lib/hosts/HiaiDocsDashboardHost.svelte +1007 -0
  13. package/frontend/src/lib/hosts/HiaiDocsExtensionProvider.svelte +20 -0
  14. package/frontend/src/lib/hosts/HiaiDocsSearchHost.svelte +996 -0
  15. package/frontend/src/lib/hosts/index.ts +25 -0
  16. package/frontend/src/lib/index.ts +65 -0
  17. package/frontend/src/lib/stores/doc-tab-registry.svelte.ts +68 -0
  18. package/package.json +178 -0
  19. package/packages/cli/src/client.ts +271 -0
  20. package/packages/cli/src/commands/config.ts +47 -0
  21. package/packages/cli/src/commands/create.ts +35 -0
  22. package/packages/cli/src/commands/delete.ts +37 -0
  23. package/packages/cli/src/commands/export.ts +36 -0
  24. package/packages/cli/src/commands/folders.ts +88 -0
  25. package/packages/cli/src/commands/history.ts +55 -0
  26. package/packages/cli/src/commands/list.ts +61 -0
  27. package/packages/cli/src/commands/read.ts +38 -0
  28. package/packages/cli/src/commands/restore.ts +30 -0
  29. package/packages/cli/src/commands/search.ts +56 -0
  30. package/packages/cli/src/commands/snapshot.ts +35 -0
  31. package/packages/cli/src/commands/update.ts +54 -0
  32. package/packages/cli/src/config.ts +83 -0
  33. package/packages/cli/src/format.ts +153 -0
  34. package/packages/cli/src/index.ts +73 -0
  35. package/packages/db/src/client.ts +20 -0
  36. package/packages/db/src/index.ts +5 -0
  37. package/packages/db/src/schema.ts +692 -0
  38. package/packages/db/src/with-tenant.ts +75 -0
  39. package/packages/mcp-server/src/client.ts +172 -0
  40. package/packages/mcp-server/src/index.ts +109 -0
  41. package/packages/mcp-server/src/tools/create-document.ts +32 -0
  42. package/packages/mcp-server/src/tools/create-folder.ts +24 -0
  43. package/packages/mcp-server/src/tools/create-snapshot.ts +30 -0
  44. package/packages/mcp-server/src/tools/export-document.ts +22 -0
  45. package/packages/mcp-server/src/tools/get-document.ts +20 -0
  46. package/packages/mcp-server/src/tools/list-documents.ts +42 -0
  47. package/packages/mcp-server/src/tools/list-folders.ts +25 -0
  48. package/packages/mcp-server/src/tools/search.ts +42 -0
  49. package/packages/mcp-server/src/tools/update-document.ts +30 -0
  50. package/packages/mcp-server/src/tools/version-history.ts +32 -0
  51. package/packages/mcp-server/src/types.ts +126 -0
  52. package/packages/sdk/dist/client.d.ts +187 -0
  53. package/packages/sdk/dist/client.js +568 -0
  54. package/packages/sdk/dist/index.d.ts +3 -0
  55. package/packages/sdk/dist/index.js +1 -0
  56. package/packages/sdk/dist/types.d.ts +391 -0
  57. 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}