@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,311 @@
1
+ <script lang="ts">
2
+ import { Button } from "@hiai-gg/hiai-ui/components/ui/button";
3
+ import {
4
+ Dialog,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from "@hiai-gg/hiai-ui/components/ui/dialog";
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from "@hiai-gg/hiai-ui/components/ui/dropdown-menu";
16
+ import { Input } from "@hiai-gg/hiai-ui/components/ui/input";
17
+ import { Label } from "@hiai-gg/hiai-ui/components/ui/label";
18
+ import { Check, Copy, FileText, Loader2, MoreVertical } from "lucide-svelte";
19
+ import { onDestroy, onMount } from "svelte";
20
+ import {
21
+ type Document,
22
+ deleteDocument,
23
+ getDocument,
24
+ listDocuments,
25
+ updateDocument,
26
+ } from "$lib/api/documents";
27
+ import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
28
+ import * as m from "$lib/paraglide/messages.js";
29
+ import {
30
+ getDocRefreshNonce,
31
+ getSelectedTag,
32
+ refreshDocs,
33
+ } from "$lib/stores/tag-store.svelte";
34
+ import { copyToClipboard } from "$lib/utils/clipboard.js";
35
+ import { cn } from "$lib/utils.js";
36
+
37
+ let recentDocs = $state<Document[]>([]);
38
+ let activeId = $state<string | null>(null);
39
+ let loadError = $state<string | null>(null);
40
+ let copiedDocId = $state<string | null>(null);
41
+ let copyLoadingDocId = $state<string | null>(null);
42
+ let copyTimer: ReturnType<typeof setTimeout> | null = null;
43
+
44
+ // Rename dialog state.
45
+ let showRenameDialog = $state(false);
46
+ let renameTarget = $state<{ id: string; title: string } | null>(null);
47
+ let renameValue = $state("");
48
+ let renameError = $state<string | null>(null);
49
+ let renameSubmitting = $state(false);
50
+
51
+ // Delete confirmation state.
52
+ let showDeleteDialog = $state(false);
53
+ let deleteTarget = $state<{ id: string; title: string } | null>(null);
54
+ let deleteBusy = $state(false);
55
+
56
+ async function fetchRecentDocs() {
57
+ try {
58
+ const tag = getSelectedTag();
59
+ const res = await listDocuments({ limit: 6, ...(tag ? { tag } : {}) });
60
+ recentDocs = res.items;
61
+ loadError = null;
62
+ } catch (e) {
63
+ console.error("RecentDocs: failed to load recent documents", e);
64
+ loadError = "Failed to load recent documents";
65
+ }
66
+ }
67
+
68
+ onMount(() => {
69
+ void fetchRecentDocs();
70
+ });
71
+
72
+ // Re-fetch the recent documents list whenever the global doc refresh
73
+ // nonce changes (e.g. after a dashboard import or another component
74
+ // calls refreshDocs()). Reading the nonce inside the effect registers
75
+ // it as a reactive dependency.
76
+ $effect(() => {
77
+ void getDocRefreshNonce();
78
+ // Re-filter when the shared selected tag changes (set from TagList).
79
+ void getSelectedTag();
80
+ void fetchRecentDocs();
81
+ });
82
+
83
+ onDestroy(() => {
84
+ if (copyTimer) {
85
+ clearTimeout(copyTimer);
86
+ copyTimer = null;
87
+ }
88
+ });
89
+
90
+ async function handleCopyContent(e: MouseEvent, docId: string) {
91
+ e.preventDefault();
92
+ e.stopPropagation();
93
+ if (typeof window === "undefined") return;
94
+ // Copy the document's full markdown source. The list endpoint returns
95
+ // `content` truncated to 200 chars at the SQL level, so we fetch the
96
+ // single-document endpoint first to get the complete text. If the
97
+ // fetch fails we fall back to whatever the list payload already has
98
+ // (excerpt, then truncated content) so the button never silently
99
+ // does nothing.
100
+ const cached = recentDocs.find((d) => d.id === docId);
101
+ let text = "";
102
+ copyLoadingDocId = docId;
103
+ try {
104
+ const full = await getDocument(docId);
105
+ text = full.content ?? "";
106
+ } catch (err) {
107
+ console.error("RecentDocs: failed to fetch full document for copy", err);
108
+ text = cached?.excerpt ?? cached?.content ?? "";
109
+ } finally {
110
+ copyLoadingDocId = null;
111
+ }
112
+ if (!text) return;
113
+ const ok = await copyToClipboard(text);
114
+ if (!ok) return;
115
+ copiedDocId = docId;
116
+ if (copyTimer) clearTimeout(copyTimer);
117
+ copyTimer = setTimeout(() => {
118
+ copiedDocId = null;
119
+ copyTimer = null;
120
+ }, 2000);
121
+ }
122
+
123
+ // --- Rename / delete ---
124
+ function startRename(id: string, title: string) {
125
+ renameTarget = { id, title };
126
+ renameValue = title;
127
+ renameError = null;
128
+ showRenameDialog = true;
129
+ }
130
+
131
+ function closeRenameDialog() {
132
+ showRenameDialog = false;
133
+ renameTarget = null;
134
+ renameValue = "";
135
+ renameError = null;
136
+ renameSubmitting = false;
137
+ }
138
+
139
+ async function submitRename(e?: Event) {
140
+ e?.preventDefault();
141
+ const target = renameTarget;
142
+ if (!target) return;
143
+ const trimmed = renameValue.trim();
144
+ if (trimmed.length === 0) {
145
+ renameError = "Name is required";
146
+ return;
147
+ }
148
+ renameSubmitting = true;
149
+ try {
150
+ await updateDocument(target.id, { title: trimmed });
151
+ closeRenameDialog();
152
+ await fetchRecentDocs();
153
+ // Notify the other sidebar lists (FolderTree) to refetch.
154
+ refreshDocs();
155
+ } catch (err) {
156
+ console.error("RecentDocs: rename failed", err);
157
+ renameError = err instanceof Error ? err.message : m.error_generic();
158
+ } finally {
159
+ renameSubmitting = false;
160
+ }
161
+ }
162
+
163
+ function startDelete(id: string, title: string) {
164
+ deleteTarget = { id, title };
165
+ showDeleteDialog = true;
166
+ }
167
+
168
+ function cancelDelete() {
169
+ showDeleteDialog = false;
170
+ deleteTarget = null;
171
+ deleteBusy = false;
172
+ }
173
+
174
+ async function confirmDelete() {
175
+ const target = deleteTarget;
176
+ if (!target || deleteBusy) return;
177
+ deleteBusy = true;
178
+ try {
179
+ await deleteDocument(target.id);
180
+ cancelDelete();
181
+ await fetchRecentDocs();
182
+ refreshDocs();
183
+ } catch (err) {
184
+ console.error("RecentDocs: delete failed", err);
185
+ loadError = err instanceof Error ? err.message : m.error_generic();
186
+ deleteBusy = false;
187
+ }
188
+ }
189
+ </script>
190
+
191
+ <div class="space-y-1">
192
+ <h3 class="mb-2 px-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">{m.sidebar_recent()}</h3>
193
+ {#if loadError}
194
+ <p class="px-2 text-xs text-destructive">{loadError}</p>
195
+ {/if}
196
+ {#each recentDocs as doc (doc.id)}
197
+ <div class="group/doc flex min-w-0 items-center gap-1">
198
+ <a
199
+ href={`/docs/${doc.id}`}
200
+ onclick={() => { activeId = doc.id; }}
201
+ class={cn(
202
+ "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
203
+ activeId === doc.id && "bg-accent text-accent-foreground"
204
+ )}
205
+ >
206
+ <FileText class="size-4 shrink-0 text-muted-foreground" />
207
+ <div class="min-w-0 flex-1">
208
+ <p class="truncate min-w-0">{doc.title}</p>
209
+ <p class="text-xs text-muted-foreground">{doc.updatedAt}</p>
210
+ </div>
211
+ </a>
212
+ <button
213
+ type="button"
214
+ class="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/doc:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring {copiedDocId === doc.id || copyLoadingDocId === doc.id ? 'opacity-100' : ''}"
215
+ aria-label={m.action_copy_content()}
216
+ title={m.action_copy_content()}
217
+ disabled={copyLoadingDocId === doc.id}
218
+ onclick={(e: MouseEvent) => void handleCopyContent(e, doc.id)}
219
+ >
220
+ {#if copyLoadingDocId === doc.id}
221
+ <Loader2 class="size-3.5 animate-spin" />
222
+ {:else if copiedDocId === doc.id}
223
+ <Check class="size-3.5" />
224
+ {:else}
225
+ <Copy class="size-3.5" />
226
+ {/if}
227
+ </button>
228
+ <DropdownMenu>
229
+ <DropdownMenuTrigger>
230
+ {#snippet child({ props })}
231
+ <button
232
+ {...props}
233
+ type="button"
234
+ class="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-accent-foreground group-hover/doc:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
235
+ aria-label={m.editor_more_options()}
236
+ title={m.editor_more_options()}
237
+ onclick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}
238
+ >
239
+ <MoreVertical class="size-3.5" />
240
+ </button>
241
+ {/snippet}
242
+ </DropdownMenuTrigger>
243
+ <DropdownMenuContent align="end">
244
+ <DropdownMenuItem onSelect={() => startRename(doc.id, doc.title)}>
245
+ {m.folders_rename()}
246
+ </DropdownMenuItem>
247
+ <DropdownMenuItem
248
+ class="text-destructive focus:text-destructive"
249
+ onSelect={() => startDelete(doc.id, doc.title)}
250
+ >
251
+ {m.action_delete()}
252
+ </DropdownMenuItem>
253
+ </DropdownMenuContent>
254
+ </DropdownMenu>
255
+ </div>
256
+ {/each}
257
+ </div>
258
+
259
+ <!-- Rename dialog -->
260
+ <Dialog bind:open={showRenameDialog} onOpenChange={(next) => { if (!next) closeRenameDialog(); }}>
261
+ <DialogHeader>
262
+ <DialogTitle>{m.folders_rename()}</DialogTitle>
263
+ <DialogDescription>{m.doc_title_label()}</DialogDescription>
264
+ </DialogHeader>
265
+
266
+ <form onsubmit={submitRename} class="space-y-4">
267
+ <div class="space-y-2">
268
+ <Label for="recent-rename-input">{m.doc_title_label()}</Label>
269
+ <Input
270
+ id="recent-rename-input"
271
+ name="name"
272
+ type="text"
273
+ bind:value={renameValue}
274
+ maxlength={255}
275
+ required
276
+ disabled={renameSubmitting}
277
+ aria-invalid={renameError ? "true" : undefined}
278
+ aria-describedby={renameError ? "recent-rename-input-error" : undefined}
279
+ autocomplete="off"
280
+ />
281
+ {#if renameError}
282
+ <p id="recent-rename-input-error" class="text-xs text-destructive" role="alert">{renameError}</p>
283
+ {/if}
284
+ </div>
285
+ </form>
286
+
287
+ <DialogFooter>
288
+ <Button variant="outline" type="button" onclick={closeRenameDialog} disabled={renameSubmitting}>
289
+ {m.action_cancel()}
290
+ </Button>
291
+ <Button
292
+ type="submit"
293
+ onclick={submitRename}
294
+ disabled={renameSubmitting || renameValue.trim().length === 0}
295
+ >
296
+ {renameSubmitting ? m.action_loading() : m.action_save()}
297
+ </Button>
298
+ </DialogFooter>
299
+ </Dialog>
300
+
301
+ <!-- Delete confirmation -->
302
+ <ConfirmDialog
303
+ bind:open={showDeleteDialog}
304
+ title={m.doc_delete()}
305
+ description={m.doc_delete_confirm()}
306
+ confirmLabel={m.action_delete()}
307
+ variant="destructive"
308
+ busy={deleteBusy}
309
+ onConfirm={confirmDelete}
310
+ onCancel={cancelDelete}
311
+ />
@@ -0,0 +1,156 @@
1
+ <script lang="ts">
2
+ import {
3
+ Clock,
4
+ Folder,
5
+ PanelLeftClose,
6
+ PanelLeftOpen,
7
+ Search,
8
+ Settings as SettingsIcon,
9
+ Tag,
10
+ } from "lucide-svelte";
11
+ import { goto } from "$app/navigation";
12
+ import SearchBar from "$lib/components/SearchBar.svelte";
13
+ import SettingsDialog from "$lib/components/SettingsDialog.svelte";
14
+ import FolderTree from "$lib/components/sidebar/FolderTree.svelte";
15
+ import RecentDocs from "$lib/components/sidebar/RecentDocs.svelte";
16
+ import TagList from "$lib/components/sidebar/TagList.svelte";
17
+ import * as m from "$lib/paraglide/messages.js";
18
+ import { cn } from "$lib/utils";
19
+
20
+ let collapsed = $state(false);
21
+ let showSettings = $state(false);
22
+ type PanelMode = "all" | "recent" | "tags";
23
+ let activePanel = $state<PanelMode>("all");
24
+
25
+ function openSearch() {
26
+ goto("/search");
27
+ }
28
+
29
+ function togglePanel(mode: PanelMode) {
30
+ activePanel = activePanel === mode ? "all" : mode;
31
+ collapsed = false;
32
+ }
33
+
34
+ function toggleCollapse() {
35
+ collapsed = !collapsed;
36
+ if (collapsed) {
37
+ activePanel = "all";
38
+ }
39
+ }
40
+ </script>
41
+
42
+ <aside class={cn(
43
+ "relative flex h-screen flex-col border-r border-border bg-card transition-[width] duration-200",
44
+ collapsed ? "w-12" : "w-64"
45
+ )}>
46
+ <!-- Toggle -->
47
+ <button
48
+ onclick={toggleCollapse}
49
+ class="absolute -right-3 top-4 z-50 flex size-6 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-accent"
50
+ >
51
+ {#if collapsed}
52
+ <PanelLeftOpen class="size-3.5" />
53
+ {:else}
54
+ <PanelLeftClose class="size-3.5" />
55
+ {/if}
56
+ </button>
57
+
58
+ {#if !collapsed}
59
+ <div class="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
60
+ <!-- Search — leave a right gap so the collapse toggle button
61
+ (positioned at the panel's top-right edge) stays clear of it. -->
62
+ <SearchBar class="mr-5" />
63
+
64
+ {#if activePanel === "all"}
65
+ <!-- Folders -->
66
+ <FolderTree />
67
+
68
+ <!-- Separator -->
69
+ <div class="h-px bg-border"></div>
70
+ {/if}
71
+
72
+ {#if activePanel === "all" || activePanel === "recent"}
73
+ <!-- Recent Docs -->
74
+ <RecentDocs />
75
+
76
+ <!-- Separator -->
77
+ <div class="h-px bg-border"></div>
78
+ {/if}
79
+
80
+ {#if activePanel === "all" || activePanel === "tags"}
81
+ <!-- Tags -->
82
+ <TagList />
83
+ {/if}
84
+ </div>
85
+ {:else}
86
+ <div class="flex flex-1 flex-col items-center gap-1 pt-14">
87
+ <button
88
+ onclick={() => goto("/")}
89
+ class="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
90
+ title={m.sidebar_folders()}
91
+ aria-label={m.sidebar_folders()}
92
+ >
93
+ <Folder class="size-4" />
94
+ </button>
95
+ <button
96
+ onclick={openSearch}
97
+ class="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
98
+ title={m.search_title()}
99
+ aria-label={m.search_title()}
100
+ >
101
+ <Search class="size-4" />
102
+ </button>
103
+ <button
104
+ onclick={() => togglePanel("recent")}
105
+ class="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
106
+ title={m.sidebar_recent()}
107
+ aria-label={m.sidebar_recent()}
108
+ >
109
+ <Clock class="size-4" />
110
+ </button>
111
+ <button
112
+ onclick={() => togglePanel("tags")}
113
+ class="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
114
+ title={m.doc_tags()}
115
+ aria-label={m.doc_tags()}
116
+ >
117
+ <Tag class="size-4" />
118
+ </button>
119
+ </div>
120
+ {/if}
121
+
122
+ <div class={cn("p-2", collapsed ? "flex flex-col items-center gap-2" : "space-y-2")}>
123
+ <button
124
+ type="button"
125
+ onclick={() => { showSettings = true; }}
126
+ class={cn(
127
+ "flex items-center rounded-md text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground",
128
+ collapsed ? "size-8 justify-center" : "w-full gap-2 px-2 py-1.5"
129
+ )}
130
+ title={m.settings_title()}
131
+ aria-label={m.settings_title()}
132
+ >
133
+ <SettingsIcon class="size-4 shrink-0" />
134
+ {#if !collapsed}
135
+ <span class="truncate">{m.settings_title()}</span>
136
+ {/if}
137
+ </button>
138
+ </div>
139
+ <div class="border-t border-border p-2">
140
+ <a
141
+ href="https://hiai.gg/docs"
142
+ target="_blank"
143
+ rel="noopener noreferrer"
144
+ class="text-xs text-muted-foreground hover:text-foreground transition-colors"
145
+ title={m.sidebar_powered_by()}
146
+ >
147
+ {#if collapsed}
148
+ HiAi
149
+ {:else}
150
+ {m.sidebar_powered_by()}
151
+ {/if}
152
+ </a>
153
+ </div>
154
+ </aside>
155
+
156
+ <SettingsDialog bind:open={showSettings} />
@@ -0,0 +1,200 @@
1
+ <!-- TagList.svelte — Sidebar list of tags with filter toggle, create, edit, delete. -->
2
+ <script lang="ts">
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuTrigger,
8
+ } from "@hiai-gg/hiai-ui/components/ui/dropdown-menu";
9
+ import { Loader2, MoreVertical, Plus } from "lucide-svelte";
10
+ import { onMount } from "svelte";
11
+ import { deleteTag, listTags, type Tag } from "$lib/api/tags";
12
+ import TagCreateDialog from "$lib/components/TagCreateDialog.svelte";
13
+ import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
14
+ import * as m from "$lib/paraglide/messages.js";
15
+ import {
16
+ getSelectedTag,
17
+ getTagRefreshNonce,
18
+ refreshTags,
19
+ setSelectedTag,
20
+ } from "$lib/stores/tag-store.svelte";
21
+ import { cn } from "$lib/utils";
22
+
23
+ let tags = $state<Tag[]>([]);
24
+ let loadError = $state<string | null>(null);
25
+ let showCreateDialog = $state(false);
26
+ let editTarget = $state<Tag | null>(null);
27
+ let showDeleteDialog = $state(false);
28
+ let deleteTarget = $state<Tag | null>(null);
29
+ let busy = $state(false);
30
+
31
+ async function refresh() {
32
+ try {
33
+ tags = await listTags();
34
+ } catch (e) {
35
+ console.error("TagList: failed to load tags", e);
36
+ loadError = m.tags_load_error();
37
+ }
38
+ }
39
+
40
+ onMount(() => {
41
+ void refresh();
42
+ });
43
+
44
+ // React to the global refresh nonce so tag mutations from other parts of
45
+ // the app (e.g. the document editor) reflect here without a page reload.
46
+ $effect(() => {
47
+ getTagRefreshNonce();
48
+ void refresh();
49
+ });
50
+
51
+ function handleCreated(created: Tag) {
52
+ // Optimistically add the new tag to the list so the user sees it
53
+ // appear immediately. The next listTags() roundtrip will reconcile.
54
+ tags = [...tags, created];
55
+ refreshTags();
56
+ void refresh();
57
+ }
58
+
59
+ function handleUpdated(updated: Tag) {
60
+ tags = tags.map((t) => (t.id === updated.id ? updated : t));
61
+ refreshTags();
62
+ }
63
+
64
+ function startEdit(t: Tag) {
65
+ editTarget = t;
66
+ showCreateDialog = true;
67
+ }
68
+
69
+ function handleDialogClose() {
70
+ // Clear the edit target whenever the dialog is dismissed so the next
71
+ // open defaults back to create mode.
72
+ editTarget = null;
73
+ }
74
+
75
+ function startDelete(t: Tag) {
76
+ deleteTarget = t;
77
+ showDeleteDialog = true;
78
+ }
79
+
80
+ function cancelDelete() {
81
+ showDeleteDialog = false;
82
+ deleteTarget = null;
83
+ }
84
+
85
+ async function confirmDelete() {
86
+ const t = deleteTarget;
87
+ if (!t || busy) return;
88
+ busy = true;
89
+ try {
90
+ await deleteTag(t.id);
91
+ tags = tags.filter((tag) => tag.id !== t.id);
92
+ if (getSelectedTag() === t.id) setSelectedTag(null);
93
+ showDeleteDialog = false;
94
+ deleteTarget = null;
95
+ refreshTags();
96
+ } catch (e) {
97
+ console.error("TagList: deleteTag failed", e);
98
+ loadError = m.error_generic();
99
+ } finally {
100
+ busy = false;
101
+ }
102
+ }
103
+ </script>
104
+
105
+ <div class="space-y-1">
106
+ <h3 class="mb-2 px-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
107
+ {m.doc_tags()}
108
+ </h3>
109
+ {#if loadError}
110
+ <p class="px-2 text-xs text-destructive">{loadError}</p>
111
+ {/if}
112
+ <div class="flex flex-wrap gap-1 px-2">
113
+ {#each tags as tag (tag.id)}
114
+ <div
115
+ class={cn(
116
+ "group/tag relative inline-flex items-center rounded-full transition-colors",
117
+ getSelectedTag() === tag.id
118
+ ? "bg-primary text-primary-foreground"
119
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80",
120
+ )}
121
+ >
122
+ <button
123
+ type="button"
124
+ onclick={() => setSelectedTag(getSelectedTag() === tag.id ? null : tag.id, tag.name)}
125
+ class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
126
+ aria-pressed={getSelectedTag() === tag.id}
127
+ >
128
+ <span
129
+ class="size-2.5 shrink-0 rounded-full"
130
+ style="background-color: {tag.color || '#888888'}"
131
+ ></span>
132
+ {tag.name}
133
+ </button>
134
+ <DropdownMenu>
135
+ <DropdownMenuTrigger>
136
+ {#snippet child({ props })}
137
+ <button
138
+ {...props}
139
+ type="button"
140
+ class={cn(
141
+ "mr-0.5 inline-flex size-5 items-center justify-center rounded-full opacity-0 transition-opacity hover:bg-black/10 focus-visible:opacity-100 focus-visible:outline-none group-hover/tag:opacity-100",
142
+ getSelectedTag() === tag.id && "opacity-100",
143
+ )}
144
+ aria-label={m.editor_more_options()}
145
+ title={m.editor_more_options()}
146
+ disabled={busy}
147
+ >
148
+ {#if busy}
149
+ <Loader2 class="size-3 animate-spin" />
150
+ {:else}
151
+ <MoreVertical class="size-3" />
152
+ {/if}
153
+ </button>
154
+ {/snippet}
155
+ </DropdownMenuTrigger>
156
+ <DropdownMenuContent align="start">
157
+ <DropdownMenuItem onSelect={() => startEdit(tag)}>
158
+ {m.action_edit()}
159
+ </DropdownMenuItem>
160
+ <DropdownMenuItem
161
+ class="text-destructive focus:text-destructive"
162
+ onSelect={() => startDelete(tag)}
163
+ >
164
+ {m.action_delete()}
165
+ </DropdownMenuItem>
166
+ </DropdownMenuContent>
167
+ </DropdownMenu>
168
+ </div>
169
+ {/each}
170
+ <button
171
+ type="button"
172
+ onclick={() => { editTarget = null; showCreateDialog = true; }}
173
+ class="inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-secondary-foreground"
174
+ aria-label={m.tags_new()}
175
+ >
176
+ <Plus class="size-3" />
177
+ {m.tags_add()}
178
+ </button>
179
+ </div>
180
+ </div>
181
+
182
+ <TagCreateDialog
183
+ bind:open={showCreateDialog}
184
+ mode={editTarget ? "edit" : "create"}
185
+ tag={editTarget}
186
+ onCreated={handleCreated}
187
+ onUpdated={handleUpdated}
188
+ onClose={handleDialogClose}
189
+ />
190
+
191
+ <ConfirmDialog
192
+ bind:open={showDeleteDialog}
193
+ title={m.tags_delete_title()}
194
+ description={m.tags_delete_description()}
195
+ confirmLabel={m.action_delete()}
196
+ variant="destructive"
197
+ busy={busy}
198
+ onConfirm={confirmDelete}
199
+ onCancel={cancelDelete}
200
+ />