@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,731 @@
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 {
19
+ Check,
20
+ ChevronRight,
21
+ Copy,
22
+ FileText,
23
+ Folder,
24
+ Loader2,
25
+ MoreVertical,
26
+ Plus,
27
+ } from "lucide-svelte";
28
+ import { onMount } from "svelte";
29
+ import { flip } from "svelte/animate";
30
+ import { type DndEvent, dndzone } from "svelte-dnd-action";
31
+ import { page } from "$app/state";
32
+ import {
33
+ type Document,
34
+ deleteDocument,
35
+ getDocument,
36
+ listDocuments,
37
+ updateDocument,
38
+ } from "$lib/api/documents";
39
+ import {
40
+ createFolder,
41
+ deleteFolder,
42
+ listFolders,
43
+ updateFolder,
44
+ } from "$lib/api/folders";
45
+ import { ConfirmDialog } from "$lib/components/ui/confirm-dialog";
46
+ import * as m from "$lib/paraglide/messages.js";
47
+ import {
48
+ getDocRefreshNonce,
49
+ getSelectedTag,
50
+ refreshDocs,
51
+ } from "$lib/stores/tag-store.svelte";
52
+ import { cn } from "$lib/utils";
53
+ import { copyToClipboard } from "$lib/utils/clipboard.js";
54
+
55
+ // Rename/delete target shared by folders and documents in the tree.
56
+ type EntityKind = "folder" | "doc";
57
+
58
+ interface FolderItem {
59
+ id: string;
60
+ name: string;
61
+ }
62
+
63
+ type DndDoc = Document & { id: string };
64
+
65
+ const FLIP_MS = 200;
66
+ const FOLDER_EXPAND_DELAY_MS = 400;
67
+
68
+ let folders = $state<FolderItem[]>([]);
69
+ // Source of truth from the server. `rootItems` and `folderDocs` are the
70
+ // per-zone working copies that `svelte-dnd-action` mutates during a drag.
71
+ let documents = $state<DndDoc[]>([]);
72
+ let originalFolderByDoc = new Map<string, string | null>();
73
+ let expandedFolderIds = $state<Set<string>>(new Set());
74
+ let loadError = $state<string | null>(null);
75
+ let dragDisabled = $state(false);
76
+ // True while a drag is in flight across any zone. Folder auto-expand on
77
+ // hover should only fire during a drag, not on plain mouseover.
78
+ let isDraggingGlobal = $state(false);
79
+
80
+ let rootItems = $state<DndDoc[]>([]);
81
+ let folderDocsMap = $state<Record<string, DndDoc[]>>({});
82
+
83
+ let showNewFolderDialog = $state(false);
84
+ let newFolderName = $state("");
85
+ let newFolderError = $state<string | null>(null);
86
+ let newFolderSubmitting = $state(false);
87
+
88
+ // Rename dialog state (shared by folders and documents).
89
+ let showRenameDialog = $state(false);
90
+ let renameTarget = $state<{
91
+ kind: EntityKind;
92
+ id: string;
93
+ name: string;
94
+ } | null>(null);
95
+ let renameValue = $state("");
96
+ let renameError = $state<string | null>(null);
97
+ let renameSubmitting = $state(false);
98
+
99
+ // Delete confirmation state (shared by folders and documents).
100
+ let showDeleteDialog = $state(false);
101
+ let deleteTarget = $state<{
102
+ kind: EntityKind;
103
+ id: string;
104
+ name: string;
105
+ } | null>(null);
106
+ let deleteBusy = $state(false);
107
+
108
+ let expandTimer: ReturnType<typeof setTimeout> | null = null;
109
+ let pendingExpandFolderId = $state<string | null>(null);
110
+
111
+ let copiedDocId = $state<string | null>(null);
112
+ let copyLoadingDocId = $state<string | null>(null);
113
+ let copyTimer: ReturnType<typeof setTimeout> | null = null;
114
+
115
+ async function handleCopyContent(docId: string) {
116
+ if (typeof window === "undefined") return;
117
+ // Copy the document's full markdown source. The list endpoint returns
118
+ // `content` truncated to 200 chars at the SQL level, so we fetch the
119
+ // single-document endpoint first to get the complete text. If the
120
+ // fetch fails we fall back to the list payload (excerpt, then
121
+ // truncated content) so the button still does something.
122
+ const cached = documents.find((d) => d.id === docId);
123
+ let text = "";
124
+ copyLoadingDocId = docId;
125
+ try {
126
+ const full = await getDocument(docId);
127
+ text = full.content ?? "";
128
+ } catch (err) {
129
+ console.error("FolderTree: failed to fetch full document for copy", err);
130
+ text = cached?.excerpt ?? cached?.content ?? "";
131
+ } finally {
132
+ copyLoadingDocId = null;
133
+ }
134
+ if (!text) return;
135
+ const ok = await copyToClipboard(text);
136
+ if (!ok) return;
137
+ copiedDocId = docId;
138
+ if (copyTimer) clearTimeout(copyTimer);
139
+ copyTimer = setTimeout(() => {
140
+ copiedDocId = null;
141
+ copyTimer = null;
142
+ }, 2000);
143
+ }
144
+
145
+ function clearExpandTimer() {
146
+ if (expandTimer !== null) {
147
+ clearTimeout(expandTimer);
148
+ expandTimer = null;
149
+ }
150
+ pendingExpandFolderId = null;
151
+ }
152
+
153
+ function scheduleFolderExpand(folderId: string) {
154
+ clearExpandTimer();
155
+ if (expandedFolderIds.has(folderId)) return;
156
+ pendingExpandFolderId = folderId;
157
+ expandTimer = setTimeout(() => {
158
+ if (
159
+ pendingExpandFolderId === folderId &&
160
+ !expandedFolderIds.has(folderId)
161
+ ) {
162
+ const next = new Set(expandedFolderIds);
163
+ next.add(folderId);
164
+ expandedFolderIds = next;
165
+ }
166
+ expandTimer = null;
167
+ pendingExpandFolderId = null;
168
+ }, FOLDER_EXPAND_DELAY_MS);
169
+ }
170
+
171
+ function sanitizeItems(raw: unknown): DndDoc[] {
172
+ if (!Array.isArray(raw)) return [];
173
+ return raw.filter(
174
+ (item): item is DndDoc =>
175
+ item !== null &&
176
+ typeof item === "object" &&
177
+ typeof (item as { id?: unknown }).id === "string",
178
+ ) as DndDoc[];
179
+ }
180
+
181
+ function buildZoneState(docs: DndDoc[]): {
182
+ root: DndDoc[];
183
+ byFolder: Record<string, DndDoc[]>;
184
+ } {
185
+ const root: DndDoc[] = [];
186
+ const byFolder: Record<string, DndDoc[]> = {};
187
+ for (const doc of docs) {
188
+ if (doc.folderId) {
189
+ const list = byFolder[doc.folderId] ?? [];
190
+ list.push(doc);
191
+ byFolder[doc.folderId] = list;
192
+ } else {
193
+ root.push(doc);
194
+ }
195
+ }
196
+ return { root, byFolder };
197
+ }
198
+
199
+ function resyncZonesFromDocuments() {
200
+ const { root, byFolder } = buildZoneState(documents);
201
+ rootItems = root;
202
+ folderDocsMap = byFolder;
203
+ }
204
+
205
+ async function loadFolders() {
206
+ try {
207
+ const result = await listFolders(null);
208
+ folders = (result[0]?.children ?? []) as FolderItem[];
209
+ loadError = null;
210
+ } catch (e) {
211
+ console.error("FolderTree: failed to load folders", e);
212
+ loadError = "Failed to load folders";
213
+ }
214
+ }
215
+
216
+ async function loadDocuments() {
217
+ try {
218
+ const tag = getSelectedTag();
219
+ const res = await listDocuments({ limit: 100, ...(tag ? { tag } : {}) });
220
+ documents = res.items as DndDoc[];
221
+ originalFolderByDoc = new Map(
222
+ documents.map((d) => [d.id, d.folderId ?? null]),
223
+ );
224
+ resyncZonesFromDocuments();
225
+ } catch (e) {
226
+ console.error("FolderTree: failed to load documents", e);
227
+ loadError = "Failed to load documents";
228
+ }
229
+ }
230
+
231
+ async function refresh() {
232
+ await Promise.all([loadFolders(), loadDocuments()]);
233
+ }
234
+
235
+ onMount(() => {
236
+ void refresh();
237
+ });
238
+
239
+ // Re-fetch folders and documents whenever the global doc refresh nonce
240
+ // changes (e.g. after a dashboard import or another component calls
241
+ // refreshDocs()). Reading the nonce inside the effect registers it as
242
+ // a reactive dependency.
243
+ $effect(() => {
244
+ void getDocRefreshNonce();
245
+ // Re-filter the tree when the shared selected tag changes (from TagList).
246
+ void getSelectedTag();
247
+ void refresh();
248
+ });
249
+
250
+ function toggleFolder(id: string) {
251
+ const wasExpanded = expandedFolderIds.has(id);
252
+ const next = new Set(expandedFolderIds);
253
+ if (wasExpanded) next.delete(id);
254
+ else next.add(id);
255
+ expandedFolderIds = next;
256
+
257
+ // When collapsing mid-drag, temporarily disable dnd so svelte-dnd-action
258
+ // does not try to measure zones that are about to be unmounted.
259
+ dragDisabled = true;
260
+ if (typeof window !== "undefined") {
261
+ window.setTimeout(() => {
262
+ dragDisabled = false;
263
+ }, FLIP_MS + 50);
264
+ }
265
+ }
266
+
267
+ function openNewFolderDialog() {
268
+ showNewFolderDialog = true;
269
+ }
270
+
271
+ function closeNewFolderDialog() {
272
+ showNewFolderDialog = false;
273
+ newFolderName = "";
274
+ newFolderError = null;
275
+ newFolderSubmitting = false;
276
+ }
277
+
278
+ async function handleCreateFolder(e: Event) {
279
+ e.preventDefault();
280
+ newFolderError = null;
281
+
282
+ const trimmed = newFolderName.trim();
283
+ if (trimmed.length === 0) {
284
+ newFolderError = "Name is required";
285
+ return;
286
+ }
287
+
288
+ newFolderSubmitting = true;
289
+ try {
290
+ await createFolder({ name: trimmed, parentId: null });
291
+ closeNewFolderDialog();
292
+ await loadFolders();
293
+ } catch (err) {
294
+ console.error("FolderTree: createFolder failed", err);
295
+ newFolderError = err instanceof Error ? err.message : m.error_generic();
296
+ } finally {
297
+ newFolderSubmitting = false;
298
+ }
299
+ }
300
+
301
+ function setZoneItems(zoneFolderId: string | null, next: DndDoc[]) {
302
+ if (zoneFolderId === null) {
303
+ rootItems = next;
304
+ return;
305
+ }
306
+ folderDocsMap = { ...folderDocsMap, [zoneFolderId]: next };
307
+ }
308
+
309
+ function handleConsider(zoneFolderId: string | null) {
310
+ return (e: CustomEvent<DndEvent<DndDoc>>) => {
311
+ isDraggingGlobal = true;
312
+ const next = sanitizeItems(e.detail.items);
313
+ setZoneItems(zoneFolderId, next);
314
+ clearExpandTimer();
315
+ };
316
+ }
317
+
318
+ function handleFinalize(zoneFolderId: string | null) {
319
+ return (e: CustomEvent<DndEvent<DndDoc>>) => {
320
+ const next = sanitizeItems(e.detail.items);
321
+ setZoneItems(zoneFolderId, next);
322
+ clearExpandTimer();
323
+ void persistZoneChanges(zoneFolderId, next);
324
+ isDraggingGlobal = false;
325
+ };
326
+ }
327
+
328
+ async function persistZoneChanges(
329
+ zoneFolderId: string | null,
330
+ zoneItems: DndDoc[],
331
+ ) {
332
+ const target: string | null = zoneFolderId;
333
+ const updates: Array<{ id: string; folderId: string | null }> = [];
334
+ for (const item of zoneItems) {
335
+ const original = originalFolderByDoc.get(item.id);
336
+ if (original === undefined) continue;
337
+ const current = item.folderId ?? null;
338
+ if (current !== target || (target !== null && current !== target)) {
339
+ updates.push({ id: item.id, folderId: target });
340
+ }
341
+ }
342
+ if (updates.length === 0) return;
343
+ try {
344
+ await Promise.all(
345
+ updates.map((u) => updateDocument(u.id, { folderId: u.folderId })),
346
+ );
347
+ } catch (err) {
348
+ console.error("FolderTree: persist failed", err);
349
+ } finally {
350
+ await refresh();
351
+ }
352
+ }
353
+
354
+ // --- Rename / delete (folders and documents) ---
355
+ function startRename(kind: EntityKind, id: string, name: string) {
356
+ renameTarget = { kind, id, name };
357
+ renameValue = name;
358
+ renameError = null;
359
+ showRenameDialog = true;
360
+ }
361
+
362
+ function closeRenameDialog() {
363
+ showRenameDialog = false;
364
+ renameTarget = null;
365
+ renameValue = "";
366
+ renameError = null;
367
+ renameSubmitting = false;
368
+ }
369
+
370
+ async function submitRename(e?: Event) {
371
+ e?.preventDefault();
372
+ const target = renameTarget;
373
+ if (!target) return;
374
+ const trimmed = renameValue.trim();
375
+ if (trimmed.length === 0) {
376
+ renameError = "Name is required";
377
+ return;
378
+ }
379
+ renameSubmitting = true;
380
+ try {
381
+ if (target.kind === "folder") {
382
+ await updateFolder(target.id, { name: trimmed });
383
+ } else {
384
+ await updateDocument(target.id, { title: trimmed });
385
+ }
386
+ closeRenameDialog();
387
+ await refresh();
388
+ // Notify the other sidebar lists (RecentDocs) to refetch.
389
+ refreshDocs();
390
+ } catch (err) {
391
+ console.error("FolderTree: rename failed", err);
392
+ renameError = err instanceof Error ? err.message : m.error_generic();
393
+ } finally {
394
+ renameSubmitting = false;
395
+ }
396
+ }
397
+
398
+ function startDelete(kind: EntityKind, id: string, name: string) {
399
+ deleteTarget = { kind, id, name };
400
+ showDeleteDialog = true;
401
+ }
402
+
403
+ function cancelDelete() {
404
+ showDeleteDialog = false;
405
+ deleteTarget = null;
406
+ deleteBusy = false;
407
+ }
408
+
409
+ async function confirmDelete() {
410
+ const target = deleteTarget;
411
+ if (!target || deleteBusy) return;
412
+ deleteBusy = true;
413
+ try {
414
+ if (target.kind === "folder") {
415
+ // Deleting a folder moves its documents back to the root: the
416
+ // documents.folder_id foreign key is ON DELETE SET NULL, so the
417
+ // documents survive and reappear at the top level.
418
+ await deleteFolder(target.id);
419
+ } else {
420
+ await deleteDocument(target.id);
421
+ }
422
+ cancelDelete();
423
+ await refresh();
424
+ refreshDocs();
425
+ } catch (err) {
426
+ console.error("FolderTree: delete failed", err);
427
+ loadError = err instanceof Error ? err.message : m.error_generic();
428
+ deleteBusy = false;
429
+ }
430
+ }
431
+ </script>
432
+
433
+ {#snippet docMenu(doc: DndDoc)}
434
+ <DropdownMenu>
435
+ <DropdownMenuTrigger>
436
+ {#snippet child({ props })}
437
+ <button
438
+ {...props}
439
+ type="button"
440
+ 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"
441
+ aria-label={m.editor_more_options()}
442
+ title={m.editor_more_options()}
443
+ onclick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}
444
+ >
445
+ <MoreVertical class="size-3.5" />
446
+ </button>
447
+ {/snippet}
448
+ </DropdownMenuTrigger>
449
+ <DropdownMenuContent align="end">
450
+ <DropdownMenuItem onSelect={() => startRename("doc", doc.id, doc.title)}>
451
+ {m.folders_rename()}
452
+ </DropdownMenuItem>
453
+ <DropdownMenuItem
454
+ class="text-destructive focus:text-destructive"
455
+ onSelect={() => startDelete("doc", doc.id, doc.title)}
456
+ >
457
+ {m.action_delete()}
458
+ </DropdownMenuItem>
459
+ </DropdownMenuContent>
460
+ </DropdownMenu>
461
+ {/snippet}
462
+
463
+ <div class="space-y-1">
464
+ <a
465
+ href="/"
466
+ class="mb-2 block px-2 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
467
+ title={m.dashboard_title()}
468
+ >{m.sidebar_folders()}</a>
469
+ {#if loadError}
470
+ <p class="px-2 text-xs text-destructive">{loadError}</p>
471
+ {/if}
472
+
473
+ <div
474
+ class="min-h-[8px] space-y-0.5"
475
+ use:dndzone={{ items: rootItems, flipDurationMs: FLIP_MS, type: "doc", dropTargetStyle: {}, dragDisabled }}
476
+ onconsider={handleConsider(null)}
477
+ onfinalize={handleFinalize(null)}
478
+ >
479
+ {#each rootItems as doc (doc.id)}
480
+ <div animate:flip={{ duration: FLIP_MS }} class="group/doc flex w-full min-w-0 items-center gap-1">
481
+ <a
482
+ href={`/docs/${doc.id}`}
483
+ data-sveltekit-noscroll
484
+ class={cn(
485
+ "flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
486
+ page.params.id === doc.id && "bg-accent text-accent-foreground font-medium"
487
+ )}
488
+ >
489
+ <span class="w-3.5 shrink-0"></span>
490
+ <FileText class="size-4 shrink-0 text-muted-foreground" />
491
+ <span class="min-w-0 truncate">{doc.title}</span>
492
+ </a>
493
+ <button
494
+ type="button"
495
+ 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' : ''}"
496
+ aria-label={m.action_copy_content()}
497
+ title={m.action_copy_content()}
498
+ disabled={copyLoadingDocId === doc.id}
499
+ onclick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); void handleCopyContent(doc.id); }}
500
+ >
501
+ {#if copyLoadingDocId === doc.id}
502
+ <Loader2 class="size-3.5 animate-spin" />
503
+ {:else if copiedDocId === doc.id}
504
+ <Check class="size-3.5" />
505
+ {:else}
506
+ <Copy class="size-3.5" />
507
+ {/if}
508
+ </button>
509
+ {@render docMenu(doc)}
510
+ </div>
511
+ {/each}
512
+ </div>
513
+
514
+ {#each folders as folder (folder.id)}
515
+ {@const isExpanded = expandedFolderIds.has(folder.id)}
516
+ {@const folderDocs = folderDocsMap[folder.id] ?? []}
517
+ <div
518
+ role="group"
519
+ aria-label={folder.name}
520
+ onmouseenter={() => {
521
+ if (isDraggingGlobal && !expandedFolderIds.has(folder.id))
522
+ scheduleFolderExpand(folder.id);
523
+ }}
524
+ onmouseleave={() => {
525
+ if (isDraggingGlobal) clearExpandTimer();
526
+ }}
527
+ >
528
+ <div class="group/folder flex w-full min-w-0 items-center gap-1">
529
+ <button
530
+ type="button"
531
+ onclick={() => toggleFolder(folder.id)}
532
+ aria-expanded={isExpanded}
533
+ class={cn(
534
+ "flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
535
+ page.params.id === folder.id && "bg-accent text-accent-foreground font-medium"
536
+ )}
537
+ >
538
+ <ChevronRight class={cn("size-3.5 shrink-0 transition-transform", isExpanded && "rotate-90")} />
539
+ <Folder class="size-4 shrink-0 text-muted-foreground" />
540
+ <span class="min-w-0 truncate">{folder.name}</span>
541
+ </button>
542
+ <DropdownMenu>
543
+ <DropdownMenuTrigger>
544
+ {#snippet child({ props })}
545
+ <button
546
+ {...props}
547
+ type="button"
548
+ 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/folder:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
549
+ aria-label={m.editor_more_options()}
550
+ title={m.editor_more_options()}
551
+ >
552
+ <MoreVertical class="size-3.5" />
553
+ </button>
554
+ {/snippet}
555
+ </DropdownMenuTrigger>
556
+ <DropdownMenuContent align="end">
557
+ <DropdownMenuItem onSelect={() => startRename("folder", folder.id, folder.name)}>
558
+ {m.folders_rename()}
559
+ </DropdownMenuItem>
560
+ <DropdownMenuItem
561
+ class="text-destructive focus:text-destructive"
562
+ onSelect={() => startDelete("folder", folder.id, folder.name)}
563
+ >
564
+ {m.folders_delete()}
565
+ </DropdownMenuItem>
566
+ </DropdownMenuContent>
567
+ </DropdownMenu>
568
+ </div>
569
+ {#if isExpanded}
570
+ <div class="ml-4 border-l border-border pl-1">
571
+ <div
572
+ class="min-h-[8px] space-y-0.5"
573
+ use:dndzone={{ items: folderDocs, flipDurationMs: FLIP_MS, type: "doc", dropTargetStyle: {}, dragDisabled }}
574
+ onconsider={handleConsider(folder.id)}
575
+ onfinalize={handleFinalize(folder.id)}
576
+ >
577
+ {#each folderDocs as doc (doc.id)}
578
+ <div animate:flip={{ duration: FLIP_MS }} class="group/doc flex w-full min-w-0 items-center gap-1">
579
+ <a
580
+ href={`/docs/${doc.id}`}
581
+ data-sveltekit-noscroll
582
+ class={cn(
583
+ "flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent hover:text-accent-foreground",
584
+ page.params.id === doc.id && "bg-accent text-accent-foreground font-medium"
585
+ )}
586
+ >
587
+ <span class="w-3.5 shrink-0"></span>
588
+ <FileText class="size-4 shrink-0 text-muted-foreground" />
589
+ <span class="min-w-0 truncate">{doc.title}</span>
590
+ </a>
591
+ <button
592
+ type="button"
593
+ 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' : ''}"
594
+ aria-label={m.action_copy_content()}
595
+ title={m.action_copy_content()}
596
+ disabled={copyLoadingDocId === doc.id}
597
+ onclick={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); void handleCopyContent(doc.id); }}
598
+ >
599
+ {#if copyLoadingDocId === doc.id}
600
+ <Loader2 class="size-3.5 animate-spin" />
601
+ {:else if copiedDocId === doc.id}
602
+ <Check class="size-3.5" />
603
+ {:else}
604
+ <Copy class="size-3.5" />
605
+ {/if}
606
+ </button>
607
+ {@render docMenu(doc)}
608
+ </div>
609
+ {/each}
610
+ </div>
611
+ </div>
612
+ {/if}
613
+ </div>
614
+ {/each}
615
+
616
+ <button
617
+ type="button"
618
+ onclick={openNewFolderDialog}
619
+ class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
620
+ >
621
+ <Plus class="size-3.5" />
622
+ <span>{m.folders_new()}</span>
623
+ </button>
624
+ </div>
625
+
626
+ <Dialog bind:open={showNewFolderDialog} onOpenChange={(next) => { if (!next) closeNewFolderDialog(); }}>
627
+ <DialogHeader>
628
+ <DialogTitle>{m.folders_new()}</DialogTitle>
629
+ <DialogDescription>{m.folders_name_placeholder()}</DialogDescription>
630
+ </DialogHeader>
631
+
632
+ <form onsubmit={handleCreateFolder} class="space-y-4">
633
+ <div class="space-y-2">
634
+ <Label for="new-folder-name">{m.folders_name_placeholder()}</Label>
635
+ <Input
636
+ id="new-folder-name"
637
+ name="name"
638
+ type="text"
639
+ bind:value={newFolderName}
640
+ placeholder={m.folders_name_placeholder()}
641
+ maxlength={50}
642
+ required
643
+ disabled={newFolderSubmitting}
644
+ aria-invalid={newFolderError ? "true" : undefined}
645
+ aria-describedby={newFolderError ? "new-folder-name-error" : undefined}
646
+ autocomplete="off"
647
+ />
648
+ {#if newFolderError}
649
+ <p id="new-folder-name-error" class="text-xs text-destructive" role="alert">{newFolderError}</p>
650
+ {/if}
651
+ </div>
652
+ </form>
653
+
654
+ <DialogFooter>
655
+ <Button
656
+ variant="outline"
657
+ type="button"
658
+ onclick={closeNewFolderDialog}
659
+ disabled={newFolderSubmitting}
660
+ >
661
+ {m.action_cancel()}
662
+ </Button>
663
+ <Button
664
+ type="submit"
665
+ onclick={handleCreateFolder}
666
+ disabled={newFolderSubmitting || newFolderName.trim().length === 0}
667
+ >
668
+ {newFolderSubmitting ? m.action_loading() : m.action_create()}
669
+ </Button>
670
+ </DialogFooter>
671
+ </Dialog>
672
+
673
+ <!-- Rename dialog (folders and documents) -->
674
+ <Dialog bind:open={showRenameDialog} onOpenChange={(next) => { if (!next) closeRenameDialog(); }}>
675
+ <DialogHeader>
676
+ <DialogTitle>{m.folders_rename()}</DialogTitle>
677
+ <DialogDescription>
678
+ {renameTarget?.kind === "folder" ? m.folders_name_placeholder() : m.doc_title_label()}
679
+ </DialogDescription>
680
+ </DialogHeader>
681
+
682
+ <form onsubmit={submitRename} class="space-y-4">
683
+ <div class="space-y-2">
684
+ <Label for="rename-input">
685
+ {renameTarget?.kind === "folder" ? m.folders_name_placeholder() : m.doc_title_label()}
686
+ </Label>
687
+ <Input
688
+ id="rename-input"
689
+ name="name"
690
+ type="text"
691
+ bind:value={renameValue}
692
+ maxlength={255}
693
+ required
694
+ disabled={renameSubmitting}
695
+ aria-invalid={renameError ? "true" : undefined}
696
+ aria-describedby={renameError ? "rename-input-error" : undefined}
697
+ autocomplete="off"
698
+ />
699
+ {#if renameError}
700
+ <p id="rename-input-error" class="text-xs text-destructive" role="alert">{renameError}</p>
701
+ {/if}
702
+ </div>
703
+ </form>
704
+
705
+ <DialogFooter>
706
+ <Button variant="outline" type="button" onclick={closeRenameDialog} disabled={renameSubmitting}>
707
+ {m.action_cancel()}
708
+ </Button>
709
+ <Button
710
+ type="submit"
711
+ onclick={submitRename}
712
+ disabled={renameSubmitting || renameValue.trim().length === 0}
713
+ >
714
+ {renameSubmitting ? m.action_loading() : m.action_save()}
715
+ </Button>
716
+ </DialogFooter>
717
+ </Dialog>
718
+
719
+ <!-- Delete confirmation (folders and documents) -->
720
+ <ConfirmDialog
721
+ bind:open={showDeleteDialog}
722
+ title={deleteTarget?.kind === "folder" ? m.folders_delete_title() : m.doc_delete()}
723
+ description={deleteTarget?.kind === "folder"
724
+ ? "Delete this folder? Its documents will be moved to the root and will not be deleted."
725
+ : m.doc_delete_confirm()}
726
+ confirmLabel={m.action_delete()}
727
+ variant="destructive"
728
+ busy={deleteBusy}
729
+ onConfirm={confirmDelete}
730
+ onCancel={cancelDelete}
731
+ />