@hiai-gg/hiai-docs 0.0.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.
Files changed (216) hide show
  1. package/.all-contributorsrc +18 -0
  2. package/.claude/settings.local.json +61 -0
  3. package/.dockerignore +113 -0
  4. package/.env.example +68 -0
  5. package/.github/FUNDING.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
  8. package/.github/dependabot.yml +136 -0
  9. package/.github/pull_request_template.md +96 -0
  10. package/.github/workflows/ci.yml +283 -0
  11. package/AGENTS.md +237 -0
  12. package/CODE_OF_CONDUCT.md +134 -0
  13. package/CONTRIBUTING.md +77 -0
  14. package/Caddyfile +50 -0
  15. package/Dockerfile.backend +60 -0
  16. package/LICENSE +21 -0
  17. package/README.md +284 -0
  18. package/RELEASE_CHECKLIST.md +34 -0
  19. package/SECURITY.md +60 -0
  20. package/backend/package.json +43 -0
  21. package/backend/src/__tests__/auth-helpers.test.ts +51 -0
  22. package/backend/src/__tests__/chunker.test.ts +65 -0
  23. package/backend/src/__tests__/config.test.ts +91 -0
  24. package/backend/src/__tests__/csrf.test.ts +91 -0
  25. package/backend/src/__tests__/embedding.test.ts +48 -0
  26. package/backend/src/__tests__/rate-limit.test.ts +46 -0
  27. package/backend/src/__tests__/routes.test.ts +38 -0
  28. package/backend/src/__tests__/schema.test.ts +31 -0
  29. package/backend/src/__tests__/validation.test.ts +556 -0
  30. package/backend/src/api/middleware/auth.ts +56 -0
  31. package/backend/src/api/middleware/csrf.ts +91 -0
  32. package/backend/src/api/middleware/rate-limit.ts +77 -0
  33. package/backend/src/api/middleware/webhook-verify.ts +22 -0
  34. package/backend/src/api/routes/attachments.ts +280 -0
  35. package/backend/src/api/routes/auth.ts +52 -0
  36. package/backend/src/api/routes/collaboration.ts +121 -0
  37. package/backend/src/api/routes/documents.ts +664 -0
  38. package/backend/src/api/routes/folders.ts +226 -0
  39. package/backend/src/api/routes/search.ts +354 -0
  40. package/backend/src/api/routes/share.ts +512 -0
  41. package/backend/src/api/routes/tags.ts +247 -0
  42. package/backend/src/api/routes/versions.ts +99 -0
  43. package/backend/src/api/routes/webhooks.ts +43 -0
  44. package/backend/src/embedding/chunker.ts +74 -0
  45. package/backend/src/embedding/index.ts +117 -0
  46. package/backend/src/embedding/providers/ollama.ts +63 -0
  47. package/backend/src/embedding/providers/openrouter.ts +71 -0
  48. package/backend/src/embedding/utils.ts +13 -0
  49. package/backend/src/embedding/worker.ts +89 -0
  50. package/backend/src/index.ts +147 -0
  51. package/backend/src/lib/auth-helpers.ts +27 -0
  52. package/backend/src/lib/auth.ts +35 -0
  53. package/backend/src/lib/config.ts +73 -0
  54. package/backend/src/lib/db.ts +7 -0
  55. package/backend/src/lib/embedding-queue.ts +12 -0
  56. package/backend/src/lib/logger.ts +18 -0
  57. package/backend/src/lib/markdown-to-doc.ts +45 -0
  58. package/backend/src/lib/minio.ts +46 -0
  59. package/backend/src/lib/redis.ts +19 -0
  60. package/backend/src/lib/yjs-provider.ts +182 -0
  61. package/backend/tests/integration/_harness.ts +754 -0
  62. package/backend/tests/integration/auth.test.ts +296 -0
  63. package/backend/tests/integration/routes.documents.test.ts +459 -0
  64. package/backend/tests/integration/routes.folders.test.ts +337 -0
  65. package/backend/tests/integration/routes.search.test.ts +322 -0
  66. package/backend/tests/integration/routes.share.test.ts +773 -0
  67. package/backend/tests/integration/routes.tags.test.ts +425 -0
  68. package/backend/tests/integration/routes.versions.test.ts +233 -0
  69. package/backend/tsconfig.json +18 -0
  70. package/docker-compose.yml +218 -0
  71. package/docs/API.md +328 -0
  72. package/docs/ARCHITECTURE.md +75 -0
  73. package/docs/DEPLOYMENT.md +113 -0
  74. package/docs/PRODUCTION_STATUS.md +61 -0
  75. package/docs/openapi.json +385 -0
  76. package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
  77. package/frontend/.svelte-kit.old/env.d.ts +1 -0
  78. package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
  79. package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
  80. package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
  81. package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
  82. package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
  83. package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
  84. package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
  85. package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
  86. package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
  87. package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
  88. package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
  89. package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
  90. package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
  91. package/frontend/.svelte-kit.old/generated/root.js +3 -0
  92. package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
  93. package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
  94. package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
  95. package/frontend/.svelte-kit.old/tsconfig.json +59 -0
  96. package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
  97. package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
  98. package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
  99. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
  100. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
  101. package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
  102. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
  103. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
  104. package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
  105. package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
  106. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
  107. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
  108. package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
  109. package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
  110. package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
  111. package/frontend/Dockerfile +44 -0
  112. package/frontend/biome.json +40 -0
  113. package/frontend/components.json +18 -0
  114. package/frontend/messages/en.json +434 -0
  115. package/frontend/package.json +70 -0
  116. package/frontend/project.inlang/settings.json +12 -0
  117. package/frontend/src/app.css +6 -0
  118. package/frontend/src/app.d.ts +13 -0
  119. package/frontend/src/app.html +30 -0
  120. package/frontend/src/hooks.server.ts +10 -0
  121. package/frontend/src/hooks.ts +10 -0
  122. package/frontend/src/lib/api/attachments.ts +45 -0
  123. package/frontend/src/lib/api/client.test.ts +15 -0
  124. package/frontend/src/lib/api/client.ts +57 -0
  125. package/frontend/src/lib/api/documents.ts +83 -0
  126. package/frontend/src/lib/api/folders.ts +180 -0
  127. package/frontend/src/lib/api/search.test.ts +52 -0
  128. package/frontend/src/lib/api/search.ts +128 -0
  129. package/frontend/src/lib/api/settings.ts +95 -0
  130. package/frontend/src/lib/api/share.ts +71 -0
  131. package/frontend/src/lib/api/tags.test.ts +91 -0
  132. package/frontend/src/lib/api/tags.ts +87 -0
  133. package/frontend/src/lib/auth-client.ts +10 -0
  134. package/frontend/src/lib/collaboration.ts +63 -0
  135. package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
  136. package/frontend/src/lib/components/DatePicker.svelte +322 -0
  137. package/frontend/src/lib/components/DocumentCard.svelte +166 -0
  138. package/frontend/src/lib/components/EmptyState.svelte +49 -0
  139. package/frontend/src/lib/components/FolderCard.svelte +93 -0
  140. package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
  141. package/frontend/src/lib/components/SearchBar.svelte +47 -0
  142. package/frontend/src/lib/components/SearchResult.svelte +115 -0
  143. package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
  144. package/frontend/src/lib/components/ShareDialog.svelte +158 -0
  145. package/frontend/src/lib/components/ShareLink.svelte +98 -0
  146. package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
  147. package/frontend/src/lib/components/VersionDiff.svelte +55 -0
  148. package/frontend/src/lib/components/VersionHistory.svelte +96 -0
  149. package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
  150. package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
  151. package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
  152. package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
  153. package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
  154. package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
  155. package/frontend/src/lib/components/editor/markdown.ts +38 -0
  156. package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
  157. package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
  158. package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
  159. package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
  160. package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
  161. package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
  162. package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
  163. package/frontend/src/lib/stores/theme.svelte.ts +97 -0
  164. package/frontend/src/lib/svelte.d.ts +6 -0
  165. package/frontend/src/lib/types.ts +44 -0
  166. package/frontend/src/lib/utils/clipboard.ts +17 -0
  167. package/frontend/src/lib/utils/strip-markdown.ts +59 -0
  168. package/frontend/src/lib/utils.ts +33 -0
  169. package/frontend/src/routes/(app)/+layout.svelte +17 -0
  170. package/frontend/src/routes/(app)/+page.server.ts +10 -0
  171. package/frontend/src/routes/(app)/+page.svelte +303 -0
  172. package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
  173. package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
  174. package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
  175. package/frontend/src/routes/(app)/search/+page.svelte +593 -0
  176. package/frontend/src/routes/(app)/search/+page.ts +25 -0
  177. package/frontend/src/routes/+error.svelte +12 -0
  178. package/frontend/src/routes/+layout.svelte +18 -0
  179. package/frontend/src/routes/+layout.ts +2 -0
  180. package/frontend/src/routes/api/[...path]/+server.ts +111 -0
  181. package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
  182. package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
  183. package/frontend/src/routes/folders/[id]/+page.ts +14 -0
  184. package/frontend/src/routes/login/+page.svelte +90 -0
  185. package/frontend/src/routes/register/+page.svelte +97 -0
  186. package/frontend/src/routes/s/[token]/+page.svelte +496 -0
  187. package/frontend/src/routes/s/[token]/+page.ts +5 -0
  188. package/frontend/src/routes/settings/+page.svelte +175 -0
  189. package/frontend/static/favicon.png +0 -0
  190. package/frontend/static/logo.png +0 -0
  191. package/frontend/svelte.config.js +15 -0
  192. package/frontend/tsconfig.json +15 -0
  193. package/frontend/vite.config.ts +25 -0
  194. package/init.sql +9 -0
  195. package/logo.png +0 -0
  196. package/package.json +39 -0
  197. package/package.public.json +39 -0
  198. package/packages/db/drizzle.config.ts +10 -0
  199. package/packages/db/package.json +30 -0
  200. package/packages/db/src/client.ts +9 -0
  201. package/packages/db/src/index.ts +2 -0
  202. package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
  203. package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
  204. package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
  205. package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
  206. package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
  207. package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
  208. package/packages/db/src/migrations/meta/_journal.json +27 -0
  209. package/packages/db/src/schema.ts +378 -0
  210. package/packages/db/tsconfig.json +17 -0
  211. package/scripts/export-openapi.ts +37 -0
  212. package/scripts/health-check.sh +75 -0
  213. package/scripts/migrate.sh +135 -0
  214. package/scripts/prework_backup.sh +25 -0
  215. package/scripts/release.sh +83 -0
  216. package/tsconfig.json +25 -0
@@ -0,0 +1,24 @@
1
+ import { getDocument } from "$lib/api/documents";
2
+ import type { PageLoad } from "./$types";
3
+
4
+ export const load: PageLoad = async ({ params, fetch }) => {
5
+ try {
6
+ const document = await getDocument(params.id, fetch);
7
+ return { document };
8
+ } catch {
9
+ // Fallback when backend unavailable
10
+ return {
11
+ document: {
12
+ id: params.id,
13
+ title: "Untitled Document",
14
+ content: "",
15
+ folderId: null,
16
+ folderName: "",
17
+ tags: [],
18
+ createdAt: new Date().toISOString(),
19
+ updatedAt: new Date().toISOString(),
20
+ excerpt: "",
21
+ } as Awaited<ReturnType<typeof getDocument>>,
22
+ };
23
+ }
24
+ };
@@ -0,0 +1,593 @@
1
+ <script lang="ts">
2
+ import {
3
+ Calendar,
4
+ ChevronLeft,
5
+ ChevronRight,
6
+ FileSearch,
7
+ Folder,
8
+ Loader2,
9
+ RotateCcw,
10
+ Search,
11
+ SlidersHorizontal,
12
+ Tag,
13
+ X,
14
+ } from "lucide-svelte";
15
+ import { goto } from "$app/navigation";
16
+ import { getFilterOptions, type SearchResponse, search } from "$lib/api/search";
17
+ import DatePicker from "$lib/components/DatePicker.svelte";
18
+ import SearchResult from "$lib/components/SearchResult.svelte";
19
+ import * as m from "$lib/paraglide/messages.js";
20
+ import { getSelectedTagName } from "$lib/stores/tag-store.svelte";
21
+
22
+ const { data } = $props();
23
+
24
+ // --- State -------------------------------------------------------------------
25
+ let query = $state("");
26
+ let activeFolder = $state("");
27
+ let activeTags = $state<string[]>([]);
28
+ let dateFrom = $state("");
29
+ let dateTo = $state("");
30
+ let currentPage = $state(1);
31
+ let sortOrder = $state<
32
+ "relevance" | "date_desc" | "date_asc" | "name_asc" | "name_desc"
33
+ >("relevance");
34
+
35
+ $effect(() => {
36
+ query = data.query ?? "";
37
+ activeFolder = data.filters?.folder ?? "";
38
+ activeTags = data.filters?.tags ?? [];
39
+ dateFrom = data.filters?.dateFrom ?? "";
40
+ dateTo = data.filters?.dateTo ?? "";
41
+ currentPage = data.page ?? 1;
42
+ });
43
+
44
+ let searchResponse = $state<SearchResponse | null>(null);
45
+ let loading = $state(false);
46
+ let showFilters = $state(false);
47
+
48
+ let folders = $state<string[]>([]);
49
+ let tags = $state<Array<{ id: string; name: string; color: string | null }>>(
50
+ [],
51
+ );
52
+
53
+ const PAGE_SIZE = 5;
54
+
55
+ // --- Derived -----------------------------------------------------------------
56
+ const totalPages = $derived(
57
+ searchResponse ? Math.ceil(searchResponse.total / PAGE_SIZE) : 0,
58
+ );
59
+
60
+ const hasActiveFilters = $derived(
61
+ activeFolder !== "" ||
62
+ activeTags.length > 0 ||
63
+ dateFrom !== "" ||
64
+ dateTo !== "",
65
+ );
66
+
67
+ // --- Effects -----------------------------------------------------------------
68
+
69
+ // Load filter options on mount
70
+ $effect(() => {
71
+ getFilterOptions().then((opts) => {
72
+ folders = opts.folders;
73
+ tags = opts.tags;
74
+ });
75
+ });
76
+
77
+ // Run search when query or filters change
78
+ $effect(() => {
79
+ const q = data.query;
80
+ const p = data.page;
81
+ const sort = sortOrder;
82
+ const folder = activeFolder;
83
+ const tags = activeTags;
84
+ const from = dateFrom;
85
+ const to = dateTo;
86
+ // Merge the shared selected tag (set from the sidebar TagList) into the
87
+ // search filter so a tag picked anywhere also narrows search results.
88
+ const sharedTag = getSelectedTagName();
89
+ const effectiveTags =
90
+ sharedTag && !tags.includes(sharedTag) ? [...tags, sharedTag] : tags;
91
+
92
+ if (!q) {
93
+ searchResponse = null;
94
+ loading = false;
95
+ return;
96
+ }
97
+
98
+ loading = true;
99
+
100
+ search(q, p, PAGE_SIZE, sort, {
101
+ folder: folder || undefined,
102
+ tags: effectiveTags.length > 0 ? effectiveTags : undefined,
103
+ dateFrom: from || undefined,
104
+ dateTo: to || undefined,
105
+ }).then((res) => {
106
+ searchResponse = res;
107
+ loading = false;
108
+ });
109
+ });
110
+
111
+ // --- Helpers -----------------------------------------------------------------
112
+
113
+ function buildUrl(overrides: Record<string, string | undefined>) {
114
+ const params = new URLSearchParams();
115
+
116
+ const q = overrides.q ?? query;
117
+ const folder = overrides.folder ?? activeFolder;
118
+ const t = overrides.tags ?? activeTags.join(",");
119
+ const df = overrides.dateFrom ?? dateFrom;
120
+ const dt = overrides.dateTo ?? dateTo;
121
+ const p = overrides.page ?? String(currentPage);
122
+
123
+ if (q) params.set("q", q);
124
+ if (folder) params.set("folder", folder);
125
+ if (t) params.set("tags", t);
126
+ if (df) params.set("dateFrom", df);
127
+ if (dt) params.set("dateTo", dt);
128
+ if (p && p !== "1") params.set("page", p);
129
+
130
+ return `/search?${params.toString()}`;
131
+ }
132
+
133
+ function handleSubmit(e: SubmitEvent) {
134
+ e.preventDefault();
135
+ currentPage = 1;
136
+ goto(buildUrl({ q: query, page: "1" }), { replaceState: true });
137
+ }
138
+
139
+ function handleKeydown(e: KeyboardEvent) {
140
+ if (e.key === "Escape") {
141
+ if (showFilters) {
142
+ showFilters = false;
143
+ } else if (query || hasActiveFilters) {
144
+ clearSearch();
145
+ }
146
+ }
147
+ }
148
+
149
+ function clearSearch() {
150
+ query = "";
151
+ activeFolder = "";
152
+ activeTags = [];
153
+ dateFrom = "";
154
+ dateTo = "";
155
+ currentPage = 1;
156
+ goto("/search", { replaceState: true });
157
+ }
158
+
159
+ function toggleFolder(folder: string) {
160
+ activeFolder = activeFolder === folder ? "" : folder;
161
+ currentPage = 1;
162
+ goto(buildUrl({ folder: activeFolder, page: "1" }), { replaceState: true });
163
+ }
164
+
165
+ function toggleTag(tag: string) {
166
+ activeTags = activeTags.includes(tag)
167
+ ? activeTags.filter((t) => t !== tag)
168
+ : [...activeTags, tag];
169
+ currentPage = 1;
170
+ goto(buildUrl({ tags: activeTags.join(","), page: "1" }), {
171
+ replaceState: true,
172
+ });
173
+ }
174
+
175
+ function applyDateRange() {
176
+ currentPage = 1;
177
+ goto(buildUrl({ dateFrom, dateTo, page: "1" }), { replaceState: true });
178
+ }
179
+
180
+ function clearFilters() {
181
+ activeFolder = "";
182
+ activeTags = [];
183
+ dateFrom = "";
184
+ dateTo = "";
185
+ currentPage = 1;
186
+ goto(
187
+ buildUrl({
188
+ folder: undefined,
189
+ tags: undefined,
190
+ dateFrom: undefined,
191
+ dateTo: undefined,
192
+ page: "1",
193
+ }),
194
+ {
195
+ replaceState: true,
196
+ },
197
+ );
198
+ }
199
+
200
+ function goToPage(page: number) {
201
+ currentPage = page;
202
+ goto(buildUrl({ page: String(page) }), { replaceState: true });
203
+ }
204
+ </script>
205
+
206
+ <svelte:window onkeydown={handleKeydown} />
207
+
208
+ <svelte:head>
209
+ <title>{data.query ? m.search_title_with_query({query: data.query}) : m.search_title()} - {m.app_name()}</title>
210
+ </svelte:head>
211
+
212
+ <div class="mx-auto max-w-6xl px-4 py-8">
213
+ <!-- Search input -->
214
+ <form onsubmit={handleSubmit} class="relative mb-6">
215
+ <Search
216
+ class="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
217
+ />
218
+ <input
219
+ type="text"
220
+ bind:value={query}
221
+ placeholder={m.search_input_placeholder()}
222
+ 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"
223
+ />
224
+ <div class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
225
+ {#if query}
226
+ <button
227
+ type="button"
228
+ onclick={clearSearch}
229
+ class="rounded-md p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
230
+ aria-label={m.search_clear()}
231
+ >
232
+ <X class="h-4 w-4" />
233
+ </button>
234
+ {/if}
235
+ <button
236
+ type="button"
237
+ onclick={() => (showFilters = !showFilters)}
238
+ class="rounded-md p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
239
+ aria-label={m.search_toggle_filters()}
240
+ >
241
+ <SlidersHorizontal class="h-4 w-4" />
242
+ </button>
243
+ </div>
244
+ </form>
245
+
246
+ <div class="flex gap-8">
247
+ <!-- Filter sidebar -->
248
+ <aside
249
+ class="w-56 shrink-0 space-y-6"
250
+ >
251
+ {@render filterPanel()}
252
+ </aside>
253
+
254
+ <!-- Mobile filter sheet -->
255
+ {#if showFilters}
256
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
257
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
258
+ <div
259
+ class="fixed inset-0 z-50 bg-black/50 lg:hidden"
260
+ onclick={() => (showFilters = false)}
261
+ >
262
+ <div
263
+ class="absolute right-0 top-0 h-full w-72 overflow-y-auto bg-background p-6 shadow-xl"
264
+ onclick={(e) => e.stopPropagation()}
265
+ >
266
+ <div class="mb-4 flex items-center justify-between">
267
+ <h3 class="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
268
+ {m.search_filters()}
269
+ </h3>
270
+ <button
271
+ onclick={() => (showFilters = false)}
272
+ class="rounded-md p-1 text-muted-foreground hover:text-foreground"
273
+ aria-label={m.search_close_filters()}
274
+ >
275
+ <X class="h-4 w-4" />
276
+ </button>
277
+ </div>
278
+ {@render filterPanel()}
279
+ </div>
280
+ </div>
281
+ {/if}
282
+
283
+ <!-- Results area -->
284
+ <main class="min-w-0 flex-1">
285
+ {#if loading}
286
+ {@render loadingState()}
287
+ {:else if searchResponse && searchResponse.items.length > 0}
288
+ {@render resultsList()}
289
+ {:else if data.query}
290
+ {@render noResults()}
291
+ {:else}
292
+ {@render emptyState()}
293
+ {/if}
294
+ </main>
295
+ </div>
296
+ </div>
297
+
298
+ <!-- Filter panel content (reused for sidebar + mobile sheet) -->
299
+ {#snippet filterPanel()}
300
+ <div class="space-y-6">
301
+ <!-- Active filter summary -->
302
+ {#if hasActiveFilters}
303
+ <button
304
+ onclick={clearFilters}
305
+ 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"
306
+ >
307
+ <RotateCcw class="size-3" />
308
+ {m.search_clear_all_filters()}
309
+ </button>
310
+ {/if}
311
+
312
+ <!-- Folder filter -->
313
+ {#if folders.length > 0}
314
+ <div>
315
+ <h4
316
+ class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
317
+ >
318
+ <Folder class="size-3.5" />
319
+ {m.search_folders()}
320
+ </h4>
321
+ <div class="space-y-0.5">
322
+ {#each folders as folder}
323
+ <button
324
+ onclick={() => toggleFolder(folder)}
325
+ class="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-sm transition-colors {activeFolder === folder
326
+ ? 'bg-primary/10 font-medium text-primary'
327
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
328
+ >
329
+ <span class="truncate">{folder}</span>
330
+ {#if activeFolder === folder}
331
+ <X class="size-3 shrink-0" />
332
+ {/if}
333
+ </button>
334
+ {/each}
335
+ </div>
336
+ </div>
337
+ {/if}
338
+
339
+ <!-- Tag filter -->
340
+ {#if tags.length > 0}
341
+ <div>
342
+ <h4
343
+ class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
344
+ >
345
+ <Tag class="size-3.5" />
346
+ {m.search_tags()}
347
+ </h4>
348
+ <div class="flex flex-wrap gap-1.5">
349
+ {#each tags as tag (tag.id)}
350
+ <button
351
+ onclick={() => toggleTag(tag.name)}
352
+ class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium transition-colors {activeTags.includes(tag.name)
353
+ ? 'bg-primary text-primary-foreground'
354
+ : 'bg-secondary text-secondary-foreground hover:bg-secondary/80'}"
355
+ >
356
+ {#if tag.color}
357
+ <span class="mr-1.5 inline-block size-2 rounded-full" style="background-color: {tag.color}"></span>
358
+ {/if}
359
+ {tag.name}
360
+ </button>
361
+ {/each}
362
+ </div>
363
+ </div>
364
+ {/if}
365
+
366
+ <!-- Date range filter -->
367
+ <div>
368
+ <h4
369
+ class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
370
+ >
371
+ <Calendar class="size-3.5" />
372
+ {m.search_date_range()}
373
+ </h4>
374
+ <div class="space-y-2">
375
+ <div>
376
+ <label for="dateFrom" class="text-xs text-muted-foreground">{m.search_date_from()}</label>
377
+ <div class="mt-0.5">
378
+ <DatePicker
379
+ id="dateFrom"
380
+ bind:value={dateFrom}
381
+ onchange={applyDateRange}
382
+ ariaLabel={m.search_date_from()}
383
+ placeholder={m.search_date_from()}
384
+ />
385
+ </div>
386
+ </div>
387
+ <div>
388
+ <label for="dateTo" class="text-xs text-muted-foreground">{m.search_date_to()}</label>
389
+ <div class="mt-0.5">
390
+ <DatePicker
391
+ id="dateTo"
392
+ bind:value={dateTo}
393
+ onchange={applyDateRange}
394
+ ariaLabel={m.search_date_to()}
395
+ placeholder={m.search_date_to()}
396
+ />
397
+ </div>
398
+ </div>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ {/snippet}
403
+
404
+ <!-- Loading state -->
405
+ {#snippet loadingState()}
406
+ <div class="space-y-4">
407
+ {#each Array(3) as _, i}
408
+ <div class="animate-pulse rounded-lg border border-border bg-card p-5">
409
+ <div class="flex items-start justify-between gap-3">
410
+ <div class="h-5 w-2/3 rounded bg-muted"></div>
411
+ <div class="h-5 w-12 rounded-full bg-muted"></div>
412
+ </div>
413
+ <div class="mt-3 space-y-2">
414
+ <div class="h-4 w-full rounded bg-muted"></div>
415
+ <div class="h-4 w-5/6 rounded bg-muted"></div>
416
+ </div>
417
+ <div class="mt-3 flex gap-4">
418
+ <div class="h-3.5 w-20 rounded bg-muted"></div>
419
+ <div class="h-3.5 w-28 rounded bg-muted"></div>
420
+ <div class="h-3.5 w-24 rounded bg-muted"></div>
421
+ </div>
422
+ </div>
423
+ {/each}
424
+ <div class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
425
+ <Loader2 class="size-4 animate-spin" />
426
+ {m.search_searching()}
427
+ </div>
428
+ </div>
429
+ {/snippet}
430
+
431
+ <!-- Results list -->
432
+ {#snippet resultsList()}
433
+ <div>
434
+ <div class="mb-4 flex items-center justify-between gap-3">
435
+ <p class="text-sm text-muted-foreground">
436
+ {(searchResponse?.total ?? 0) === 1 ? m.search_result_for({count: searchResponse!.total}) : m.search_results_for({count: searchResponse!.total})}
437
+ "<span class="font-medium text-foreground">{data.query}</span>"
438
+ </p>
439
+ <select
440
+ bind:value={sortOrder}
441
+ class="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
442
+ aria-label={m.search_filter_label_aria()}
443
+ >
444
+ <option value="relevance">{m.sort_relevance()}</option>
445
+ <option value="date_desc">{m.sort_date_newest()}</option>
446
+ <option value="date_asc">{m.sort_date_oldest()}</option>
447
+ <option value="name_asc">{m.sort_name_asc()}</option>
448
+ <option value="name_desc">{m.sort_name_desc()}</option>
449
+ </select>
450
+ </div>
451
+
452
+ <div class="space-y-3">
453
+ {#each searchResponse?.items ?? [] as result (result.id)}
454
+ <SearchResult
455
+ id={result.id}
456
+ title={result.title}
457
+ snippet={result.snippet}
458
+ score={result.score}
459
+ folderName={result.folder_name ?? ""}
460
+ tags={result.tags ?? []}
461
+ createdAt={result.created_at}
462
+ query={data.query}
463
+ />
464
+ {/each}
465
+ </div>
466
+
467
+ <!-- Pagination -->
468
+ {#if totalPages > 1}
469
+ <nav class="mt-8 flex items-center justify-center gap-1.5" aria-label={m.search_pages_aria()}>
470
+ <button
471
+ onclick={() => goToPage(currentPage - 1)}
472
+ disabled={currentPage <= 1}
473
+ aria-label={m.search_previous_page()}
474
+ 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"
475
+ >
476
+ <ChevronLeft class="h-4 w-4" />
477
+ </button>
478
+
479
+ {#each Array(totalPages) as _, i}
480
+ {@const pageNum = i + 1}
481
+ {#if totalPages <= 7 || pageNum === 1 || pageNum === totalPages || (pageNum >= currentPage - 1 && pageNum <= currentPage + 1)}
482
+ <button
483
+ onclick={() => goToPage(pageNum)}
484
+ aria-label={m.search_page_number({num: pageNum})}
485
+ aria-current={pageNum === currentPage ? "page" : undefined}
486
+ 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
487
+ ? 'bg-primary text-primary-foreground'
488
+ : 'border border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'}"
489
+ >
490
+ {pageNum}
491
+ </button>
492
+ {:else if pageNum === 2 || pageNum === totalPages - 1}
493
+ <span class="px-1 text-muted-foreground">...</span>
494
+ {/if}
495
+ {/each}
496
+
497
+ <button
498
+ onclick={() => goToPage(currentPage + 1)}
499
+ disabled={currentPage >= totalPages}
500
+ aria-label={m.search_next_page()}
501
+ 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"
502
+ >
503
+ <ChevronRight class="h-4 w-4" />
504
+ </button>
505
+ </nav>
506
+ {/if}
507
+ </div>
508
+ {/snippet}
509
+
510
+ <!-- No results -->
511
+ {#snippet noResults()}
512
+ <div class="flex flex-col items-center justify-center py-20 text-center">
513
+ <FileSearch class="mb-4 h-16 w-16 text-muted-foreground/40" />
514
+ <h2 class="text-xl font-semibold text-foreground">{m.search_no_results()}</h2>
515
+ <p class="mt-2 max-w-sm text-sm text-muted-foreground">
516
+ {m.search_no_results_query({query: data.query ?? ""})}
517
+ {m.search_no_results_tip()}
518
+ </p>
519
+ <div class="mt-6 flex gap-3">
520
+ {#if hasActiveFilters}
521
+ <button
522
+ onclick={clearFilters}
523
+ 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"
524
+ >
525
+ <RotateCcw class="size-3.5" />
526
+ {m.search_clear_filters()}
527
+ </button>
528
+ {/if}
529
+ <button
530
+ onclick={clearSearch}
531
+ 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"
532
+ >
533
+ {m.search_new_search()}
534
+ </button>
535
+ </div>
536
+
537
+ <!-- Search suggestions -->
538
+ <div class="mt-10">
539
+ <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
540
+ {m.search_try_searching()}
541
+ </p>
542
+ <div class="flex flex-wrap justify-center gap-2">
543
+ {#each ["getting started", "docker deployment", "API reference", "authentication", "embeddings"] as suggestion}
544
+ <button
545
+ onclick={() => {
546
+ query = suggestion;
547
+ goto(buildUrl({ q: suggestion, page: "1" }), {
548
+ replaceState: true,
549
+ });
550
+ }}
551
+ 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"
552
+ >
553
+ {suggestion}
554
+ </button>
555
+ {/each}
556
+ </div>
557
+ </div>
558
+ </div>
559
+ {/snippet}
560
+
561
+ <!-- Empty state (no query yet) -->
562
+ {#snippet emptyState()}
563
+ <div class="flex flex-col items-center justify-center py-20 text-center">
564
+ <Search class="mb-4 h-16 w-16 text-muted-foreground/40" />
565
+ <h2 class="text-xl font-semibold text-foreground">
566
+ {m.search_empty_title()}
567
+ </h2>
568
+ <p class="mt-2 max-w-sm text-sm text-muted-foreground">
569
+ {m.search_empty_description()}
570
+ </p>
571
+
572
+ <div class="mt-8">
573
+ <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
574
+ {m.search_popular()}
575
+ </p>
576
+ <div class="flex flex-wrap justify-center gap-2">
577
+ {#each ["getting started", "docker deployment", "API reference", "authentication", "embeddings", "Svelte 5"] as suggestion}
578
+ <button
579
+ onclick={() => {
580
+ query = suggestion;
581
+ goto(buildUrl({ q: suggestion, page: "1" }), {
582
+ replaceState: true,
583
+ });
584
+ }}
585
+ 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"
586
+ >
587
+ {suggestion}
588
+ </button>
589
+ {/each}
590
+ </div>
591
+ </div>
592
+ </div>
593
+ {/snippet}
@@ -0,0 +1,25 @@
1
+ import type { PageLoad } from "./$types";
2
+
3
+ export const load: PageLoad = async ({ url }) => {
4
+ const q = url.searchParams.get("q") ?? "";
5
+ const folder = url.searchParams.get("folder") ?? undefined;
6
+ const tags =
7
+ url.searchParams.get("tags")?.split(",").filter(Boolean) ?? undefined;
8
+ const dateFrom = url.searchParams.get("dateFrom") ?? undefined;
9
+ const dateTo = url.searchParams.get("dateTo") ?? undefined;
10
+ const page = Math.max(
11
+ 1,
12
+ Number.parseInt(url.searchParams.get("page") ?? "1", 10),
13
+ );
14
+
15
+ return {
16
+ query: q,
17
+ filters: {
18
+ folder: folder || undefined,
19
+ tags: tags && tags.length > 0 ? tags : undefined,
20
+ dateFrom: dateFrom || undefined,
21
+ dateTo: dateTo || undefined,
22
+ },
23
+ page,
24
+ };
25
+ };
@@ -0,0 +1,12 @@
1
+ <script lang="ts">
2
+ import { page } from "$app/state";
3
+ import * as m from "$lib/paraglide/messages.js";
4
+ </script>
5
+
6
+ <div class="flex min-h-screen items-center justify-center">
7
+ <div class="text-center">
8
+ <h1 class="text-4xl font-bold">{page.status}</h1>
9
+ <p class="mt-2 text-muted-foreground">{page.error?.message ?? m.error_generic()}</p>
10
+ <a href="/" class="mt-4 inline-block text-primary underline">{m.action_back()}</a>
11
+ </div>
12
+ </div>
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import "../app.css";
3
+ import { getLocale } from "$lib/paraglide/runtime";
4
+ import { initTheme } from "$lib/stores/theme.svelte";
5
+
6
+ const { children } = $props();
7
+
8
+ initTheme();
9
+ </script>
10
+
11
+ <svelte:head>
12
+ <meta name="description" content="Self-hosted AI-first documentation platform" />
13
+ <meta name="og:type" content="website" />
14
+ <meta name="og:title" content="HiAi-Docs" />
15
+ <meta name="og:description" content="AI-first documentation platform with semantic search" />
16
+ </svelte:head>
17
+
18
+ {@render children()}
@@ -0,0 +1,2 @@
1
+ export const prerender = false;
2
+ export const ssr = true;