@hiai-gg/docsmint 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +171 -0
  2. package/README.md +348 -0
  3. package/backend/src/lib/logger.ts +18 -0
  4. package/backend/src/lib/redis-factory.ts +40 -0
  5. package/backend/src/lib/storage-factory.ts +56 -0
  6. package/frontend/src/lib/components/editor/shared-document.ts +237 -0
  7. package/frontend/src/lib/extensions/context.ts +60 -0
  8. package/frontend/src/lib/extensions/doc-tabs.ts +18 -0
  9. package/frontend/src/lib/extensions/resolve.ts +48 -0
  10. package/frontend/src/lib/extensions/types.ts +202 -0
  11. package/frontend/src/lib/hosts/DocsmintSharedDocumentHost.svelte +65 -0
  12. package/frontend/src/lib/hosts/HiaiDocsDashboardHost.svelte +1007 -0
  13. package/frontend/src/lib/hosts/HiaiDocsExtensionProvider.svelte +20 -0
  14. package/frontend/src/lib/hosts/HiaiDocsSearchHost.svelte +996 -0
  15. package/frontend/src/lib/hosts/index.ts +25 -0
  16. package/frontend/src/lib/index.ts +65 -0
  17. package/frontend/src/lib/stores/doc-tab-registry.svelte.ts +68 -0
  18. package/package.json +178 -0
  19. package/packages/cli/src/client.ts +271 -0
  20. package/packages/cli/src/commands/config.ts +47 -0
  21. package/packages/cli/src/commands/create.ts +35 -0
  22. package/packages/cli/src/commands/delete.ts +37 -0
  23. package/packages/cli/src/commands/export.ts +36 -0
  24. package/packages/cli/src/commands/folders.ts +88 -0
  25. package/packages/cli/src/commands/history.ts +55 -0
  26. package/packages/cli/src/commands/list.ts +61 -0
  27. package/packages/cli/src/commands/read.ts +38 -0
  28. package/packages/cli/src/commands/restore.ts +30 -0
  29. package/packages/cli/src/commands/search.ts +56 -0
  30. package/packages/cli/src/commands/snapshot.ts +35 -0
  31. package/packages/cli/src/commands/update.ts +54 -0
  32. package/packages/cli/src/config.ts +83 -0
  33. package/packages/cli/src/format.ts +153 -0
  34. package/packages/cli/src/index.ts +73 -0
  35. package/packages/db/src/client.ts +20 -0
  36. package/packages/db/src/index.ts +5 -0
  37. package/packages/db/src/schema.ts +692 -0
  38. package/packages/db/src/with-tenant.ts +75 -0
  39. package/packages/mcp-server/src/client.ts +172 -0
  40. package/packages/mcp-server/src/index.ts +109 -0
  41. package/packages/mcp-server/src/tools/create-document.ts +32 -0
  42. package/packages/mcp-server/src/tools/create-folder.ts +24 -0
  43. package/packages/mcp-server/src/tools/create-snapshot.ts +30 -0
  44. package/packages/mcp-server/src/tools/export-document.ts +22 -0
  45. package/packages/mcp-server/src/tools/get-document.ts +20 -0
  46. package/packages/mcp-server/src/tools/list-documents.ts +42 -0
  47. package/packages/mcp-server/src/tools/list-folders.ts +25 -0
  48. package/packages/mcp-server/src/tools/search.ts +42 -0
  49. package/packages/mcp-server/src/tools/update-document.ts +30 -0
  50. package/packages/mcp-server/src/tools/version-history.ts +32 -0
  51. package/packages/mcp-server/src/types.ts +126 -0
  52. package/packages/sdk/dist/client.d.ts +187 -0
  53. package/packages/sdk/dist/client.js +568 -0
  54. package/packages/sdk/dist/index.d.ts +3 -0
  55. package/packages/sdk/dist/index.js +1 -0
  56. package/packages/sdk/dist/types.d.ts +391 -0
  57. package/packages/sdk/dist/types.js +8 -0
@@ -0,0 +1,1007 @@
1
+ <script lang="ts">
2
+ import { Badge } from "@hiai-gg/hiai-ui/components/ui/badge";
3
+ import { Button } from "@hiai-gg/hiai-ui/components/ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from "@hiai-gg/hiai-ui/components/ui/dropdown-menu";
10
+ import { Label } from "@hiai-gg/hiai-ui/components/ui/label";
11
+ import SelectRoot from "@hiai-gg/hiai-ui/components/ui/select/select.svelte";
12
+ import SelectContent from "@hiai-gg/hiai-ui/components/ui/select/select-content.svelte";
13
+ import SelectItem from "@hiai-gg/hiai-ui/components/ui/select/select-item.svelte";
14
+ import SelectTrigger from "@hiai-gg/hiai-ui/components/ui/select/select-trigger.svelte";
15
+ import SelectValue from "@hiai-gg/hiai-ui/components/ui/select/select-value.svelte";
16
+ import {
17
+ ArrowLeft,
18
+ Calendar,
19
+ Check,
20
+ ChevronDown,
21
+ ChevronRight,
22
+ Clock,
23
+ Copy,
24
+ FileText,
25
+ Folder,
26
+ FolderKanban,
27
+ FolderOpen,
28
+ LayoutDashboard,
29
+ Loader2,
30
+ MoreVertical,
31
+ Plus,
32
+ RotateCcw,
33
+ Share2,
34
+ Tag,
35
+ Upload,
36
+ X,
37
+ } from "lucide-svelte";
38
+
39
+ const Select = {
40
+ Root: SelectRoot,
41
+ Content: SelectContent,
42
+ Item: SelectItem,
43
+ Trigger: SelectTrigger,
44
+ Value: SelectValue,
45
+ };
46
+
47
+ import { ConfirmDialog } from "@hiai-gg/hiai-ui/components/ui/confirm-dialog";
48
+ import { goto, invalidateAll } from "$app/navigation";
49
+ import { page } from "$app/state";
50
+ import type { Category } from "$lib/api/categories";
51
+ import { apiFetch } from "$lib/api/client";
52
+ import { createDocument, listDocuments } from "$lib/api/documents";
53
+ import { createFolder, duplicateDocument, listFolders } from "$lib/api/folders";
54
+ import type { Tag as ApiTag } from "$lib/api/tags";
55
+ import DatePicker from "$lib/components/DatePicker.svelte";
56
+ import DocumentCard from "$lib/components/DocumentCard.svelte";
57
+ import FolderCard from "$lib/components/FolderCard.svelte";
58
+ import FolderDialog from "$lib/components/FolderDialog.svelte";
59
+ import ImportProgress, {
60
+ type ImportItem,
61
+ } from "$lib/components/ImportProgress.svelte";
62
+ import ShareDialog from "$lib/components/ShareDialog.svelte";
63
+ import { getFrontendExtensions } from "$lib/extensions/context";
64
+ import { resolveExtensions } from "$lib/extensions/resolve";
65
+ import type { ExtensionVisibilityContext } from "$lib/extensions/types";
66
+ import * as m from "$lib/paraglide/messages.js";
67
+ import { refreshFolders } from "$lib/stores/subfolders-refresh-store.svelte.js";
68
+ import { refreshDocs } from "$lib/stores/tag-store.svelte.js";
69
+ import type { Document, Folder as FolderType } from "$lib/types.js";
70
+
71
+ export interface HiaiDocsDashboardData {
72
+ categories: Category[];
73
+ tags: ApiTag[];
74
+ activeFolder: FolderType | null;
75
+ breadcrumb: Array<{ id: string; name: string }>;
76
+ rootFolders: FolderType[];
77
+ recentDocs: Document[];
78
+ }
79
+
80
+ const { data, extensionContext = { pathname: "/" } } = $props<{
81
+ data: HiaiDocsDashboardData;
82
+ extensionContext?: ExtensionVisibilityContext;
83
+ }>();
84
+
85
+ const frontendExtensions = getFrontendExtensions();
86
+ const dashboardWidgets = $derived.by(() =>
87
+ resolveExtensions(frontendExtensions.dashboardWidgets, extensionContext),
88
+ );
89
+
90
+ // --- Query parameters via SvelteKit page state ---
91
+ const activeFolderId = $derived(page.url.searchParams.get("folder") || null);
92
+ const activeCategoryId = $derived(
93
+ page.url.searchParams.get("category") || null,
94
+ );
95
+
96
+ // --- Active Filter Inputs (Local State) ---
97
+ let searchQuery = $state("");
98
+ let selectedTagId = $state<string | null>(null);
99
+ let dateFrom = $state("");
100
+ let dateTo = $state("");
101
+
102
+ // Sync inputs with URL params if they exist
103
+ $effect(() => {
104
+ searchQuery = page.url.searchParams.get("q") ?? "";
105
+ selectedTagId = page.url.searchParams.get("tag") ?? null;
106
+ dateFrom = page.url.searchParams.get("dateFrom") ?? "";
107
+ dateTo = page.url.searchParams.get("dateTo") ?? "";
108
+ });
109
+
110
+ // --- Dialog states ---
111
+ let showFolderDialog = $state(false);
112
+ let folderDialogMode = $state<"create" | "edit">("create");
113
+ let folderDialogTarget = $state<{ id: string; name: string } | null>(null);
114
+
115
+ let showDeleteFolderDialog = $state(false);
116
+ let deleteFolderTargetId = $state<string | null>(null);
117
+ let deleteFolderBusy = $state(false);
118
+
119
+ let showShareDialog = $state(false);
120
+ type ShareTarget =
121
+ | { kind: "document"; documentId: string; title: string }
122
+ | { kind: "folder"; folderId: string; name: string }
123
+ | { kind: "category"; categoryId: string; name: string };
124
+ let shareTarget = $state<ShareTarget | null>(null);
125
+ let importOpen = $state(false);
126
+ let importItems = $state<ImportItem[]>([]);
127
+ let importInput = $state<HTMLInputElement | undefined>(undefined);
128
+ let duplicatingDocumentId = $state<string | null>(null);
129
+ let optimisticDocuments = $state<Document[]>([]);
130
+
131
+ function openShareDialogForDocument(id: string, title: string) {
132
+ shareTarget = { kind: "document", documentId: id, title };
133
+ showShareDialog = true;
134
+ }
135
+
136
+ function openShareDialogForFolder(id: string, name: string) {
137
+ shareTarget = { kind: "folder", folderId: id, name };
138
+ showShareDialog = true;
139
+ }
140
+
141
+ function openShareDialogForCategory(id: string, name: string) {
142
+ shareTarget = { kind: "category", categoryId: id, name };
143
+ showShareDialog = true;
144
+ }
145
+
146
+ // --- Mutating actions ---
147
+ function handleNewDocument() {
148
+ let url = "/docs/new";
149
+ const params = new URLSearchParams();
150
+ if (activeFolderId) params.set("folder", activeFolderId);
151
+ if (activeCategoryId) params.set("category", activeCategoryId);
152
+ const qs = params.toString();
153
+ if (qs) url += `?${qs}`;
154
+ goto(url);
155
+ }
156
+
157
+ function handleRenameFolder(id: string) {
158
+ const folder = activeFolderId
159
+ ? data.activeFolder?.children?.find((c: FolderType) => c.id === id)
160
+ : data.rootFolders.find((f: FolderType) => f.id === id);
161
+ if (!folder) return;
162
+ folderDialogMode = "edit";
163
+ folderDialogTarget = { id: folder.id, name: folder.name };
164
+ showFolderDialog = true;
165
+ }
166
+
167
+ async function saveFolder(name: string) {
168
+ if (folderDialogMode === "create") {
169
+ await apiFetch("/api/folders", {
170
+ method: "POST",
171
+ body: JSON.stringify({
172
+ name,
173
+ parentId: activeFolderId || null,
174
+ categoryId: activeFolderId ? null : activeCategoryId || null,
175
+ }),
176
+ });
177
+ } else if (folderDialogMode === "edit" && folderDialogTarget) {
178
+ await apiFetch(`/api/folders/${folderDialogTarget.id}`, {
179
+ method: "PATCH",
180
+ body: JSON.stringify({ name }),
181
+ });
182
+ }
183
+ showFolderDialog = false;
184
+ refreshFolders();
185
+ await invalidateAll();
186
+ }
187
+
188
+ function handleDeleteFolder(id: string) {
189
+ deleteFolderTargetId = id;
190
+ showDeleteFolderDialog = true;
191
+ }
192
+
193
+ async function confirmDeleteFolder() {
194
+ const id = deleteFolderTargetId;
195
+ if (!id || deleteFolderBusy) return;
196
+ deleteFolderBusy = true;
197
+ try {
198
+ await apiFetch(`/api/folders/${id}`, { method: "DELETE" });
199
+ showDeleteFolderDialog = false;
200
+ deleteFolderTargetId = null;
201
+ await invalidateAll();
202
+ refreshFolders();
203
+ refreshDocs();
204
+ } catch (e) {
205
+ console.error("Failed to delete folder", e);
206
+ } finally {
207
+ deleteFolderBusy = false;
208
+ }
209
+ }
210
+
211
+ async function handleDeleteDocument(id: string) {
212
+ if (!confirm(`${m.action_delete()}?`)) return;
213
+ try {
214
+ await apiFetch(`/api/documents/${id}`, { method: "DELETE" });
215
+ await invalidateAll();
216
+ refreshDocs();
217
+ } catch (e) {
218
+ console.error("Failed to delete document", e);
219
+ }
220
+ }
221
+
222
+ async function handleDuplicateDocument(id: string) {
223
+ if (duplicatingDocumentId) return;
224
+ duplicatingDocumentId = id;
225
+ try {
226
+ const duplicate = await duplicateDocument(id);
227
+ optimisticDocuments = [
228
+ ...optimisticDocuments.filter((doc) => doc.id !== duplicate.id),
229
+ duplicate,
230
+ ];
231
+ await invalidateAll();
232
+ const currentDocuments = data.activeFolder?.documents ?? data.recentDocs;
233
+ optimisticDocuments = optimisticDocuments.filter(
234
+ (doc) =>
235
+ !currentDocuments.some((current: Document) => current.id === doc.id),
236
+ );
237
+ refreshDocs();
238
+ } catch (e) {
239
+ console.error("Failed to duplicate document", e);
240
+ } finally {
241
+ duplicatingDocumentId = null;
242
+ }
243
+ }
244
+
245
+ // --- Import functions ---
246
+ function triggerImport() {
247
+ importInput?.click();
248
+ }
249
+
250
+ async function handleImportFile(e: Event) {
251
+ const input = e.target as HTMLInputElement;
252
+ if (!input.files || input.files.length === 0) return;
253
+ const files = Array.from(input.files);
254
+
255
+ importItems = files.map((f) => ({
256
+ filename: f.name,
257
+ status: "uploading",
258
+ }));
259
+ importOpen = true;
260
+
261
+ try {
262
+ const results = await importDocuments(files, activeFolderId || undefined);
263
+
264
+ importItems = importItems.map((item, idx) => {
265
+ const res = results.items[idx];
266
+ if (!res) return { ...item, status: "error", error: "No response" };
267
+ if (res.status === "ok") {
268
+ return {
269
+ ...item,
270
+ status: "done",
271
+ documentId: res.document?.id,
272
+ };
273
+ }
274
+ return {
275
+ ...item,
276
+ status: "error",
277
+ error: res.error || "Failed",
278
+ };
279
+ });
280
+
281
+ // The page data is immutable until SvelteKit invalidates the route. Keep
282
+ // successful imports in the dashboard's local projection immediately so
283
+ // Recent Documents updates without requiring a full reload.
284
+ const importedDocuments: Document[] = results.items
285
+ .flatMap((item) =>
286
+ item.status === "ok" && item.document ? [item.document] : [],
287
+ )
288
+ .map((doc) => {
289
+ return {
290
+ id: doc.id,
291
+ title: doc.title,
292
+ content: doc.content,
293
+ folderId: doc.folderId ?? null,
294
+ folderName: doc.folderName ?? "",
295
+ categoryId: doc.categoryId ?? null,
296
+ tags: (doc.tags ?? []).map((tag) => tag.id),
297
+ createdAt: doc.createdAt,
298
+ updatedAt: doc.updatedAt,
299
+ excerpt: doc.excerpt ?? doc.content?.slice(0, 200) ?? "",
300
+ };
301
+ });
302
+ if (importedDocuments.length > 0) {
303
+ optimisticDocuments = [
304
+ ...optimisticDocuments.filter(
305
+ (existing) =>
306
+ !importedDocuments.some((doc) => doc.id === existing.id),
307
+ ),
308
+ ...importedDocuments,
309
+ ];
310
+ }
311
+
312
+ await invalidateAll();
313
+ refreshDocs();
314
+ } catch (err) {
315
+ console.error("Import failed:", err);
316
+ importItems = importItems.map((item) => ({
317
+ ...item,
318
+ status: "error",
319
+ error: err instanceof Error ? err.message : m.error_generic(),
320
+ }));
321
+ } finally {
322
+ input.value = "";
323
+ }
324
+ }
325
+
326
+ // Helper since we import importDocuments locally in load but import is in documents API
327
+ import { importDocuments } from "$lib/api/documents";
328
+
329
+ function closeImport() {
330
+ importOpen = false;
331
+ setTimeout(() => {
332
+ importItems = [];
333
+ }, 200);
334
+ }
335
+
336
+ // --- Reset/Clear Filters ---
337
+ function clearFilters() {
338
+ searchQuery = "";
339
+ selectedTagId = null;
340
+ dateFrom = "";
341
+ dateTo = "";
342
+
343
+ const params = new URLSearchParams(page.url.searchParams);
344
+ params.delete("q");
345
+ params.delete("tag");
346
+ params.delete("dateFrom");
347
+ params.delete("dateTo");
348
+ goto(`/?${params.toString()}`);
349
+ }
350
+
351
+ function updateFilters() {
352
+ const params = new URLSearchParams(page.url.searchParams);
353
+ if (searchQuery.trim()) params.set("q", searchQuery);
354
+ else params.delete("q");
355
+
356
+ if (selectedTagId) params.set("tag", selectedTagId);
357
+ else params.delete("tag");
358
+
359
+ if (dateFrom) params.set("dateFrom", dateFrom);
360
+ else params.delete("dateFrom");
361
+
362
+ if (dateTo) params.set("dateTo", dateTo);
363
+ else params.delete("dateTo");
364
+
365
+ goto(`/?${params.toString()}`, {
366
+ replaceState: true,
367
+ keepFocus: true,
368
+ noScroll: true,
369
+ });
370
+ }
371
+
372
+ const hasActiveFilters = $derived(
373
+ searchQuery.trim() !== "" ||
374
+ selectedTagId !== null ||
375
+ dateFrom !== "" ||
376
+ dateTo !== "",
377
+ );
378
+
379
+ // --- Filtering Logic (Client Side) ---
380
+ const filteredFolders = $derived.by(() => {
381
+ let list = activeFolderId
382
+ ? (data.activeFolder?.children ?? [])
383
+ : data.rootFolders;
384
+
385
+ // Filter by active category if at root workspace
386
+ if (!activeFolderId && activeCategoryId) {
387
+ list = list.filter((f: FolderType) => f.categoryId === activeCategoryId);
388
+ }
389
+
390
+ // Filter by search query locally
391
+ if (searchQuery.trim()) {
392
+ const q = searchQuery.toLowerCase();
393
+ list = list.filter((f: FolderType) => f.name.toLowerCase().includes(q));
394
+ }
395
+
396
+ // Filter by date range (updatedAt)
397
+ if (dateFrom) {
398
+ const fromTime = new Date(dateFrom).getTime();
399
+ list = list.filter(
400
+ (f: FolderType) => new Date(f.updatedAt).getTime() >= fromTime,
401
+ );
402
+ }
403
+ if (dateTo) {
404
+ const toTime = new Date(dateTo).getTime();
405
+ list = list.filter(
406
+ (f: FolderType) => new Date(f.updatedAt).getTime() <= toTime,
407
+ );
408
+ }
409
+
410
+ return list;
411
+ });
412
+
413
+ const filteredDocuments = $derived.by(() => {
414
+ let list = activeFolderId
415
+ ? (data.activeFolder?.documents ?? [])
416
+ : data.recentDocs;
417
+ const extras = optimisticDocuments.filter(
418
+ (doc) => !list.some((current: Document) => current.id === doc.id),
419
+ );
420
+ if (extras.length > 0) list = [...list, ...extras];
421
+ // Keep the newest document first after optimistic imports/duplicates too.
422
+ // The API already returns this order, but local extras are otherwise
423
+ // appended and end up at the bottom of the single-column mobile grid.
424
+ list = [...list].sort(
425
+ (a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt),
426
+ );
427
+
428
+ // Filter by search query locally
429
+ if (searchQuery.trim()) {
430
+ const q = searchQuery.toLowerCase();
431
+ list = list.filter(
432
+ (d: Document) =>
433
+ d.title.toLowerCase().includes(q) ||
434
+ d.content?.toLowerCase().includes(q),
435
+ );
436
+ }
437
+
438
+ // Filter by tag
439
+ if (selectedTagId) {
440
+ const tagId = selectedTagId;
441
+ list = list.filter((d: Document) => d.tags.includes(tagId));
442
+ }
443
+
444
+ // Filter by date range (updatedAt)
445
+ if (dateFrom) {
446
+ const fromTime = new Date(dateFrom).getTime();
447
+ list = list.filter(
448
+ (d: Document) => new Date(d.updatedAt).getTime() >= fromTime,
449
+ );
450
+ }
451
+ if (dateTo) {
452
+ const toTime = new Date(dateTo).getTime();
453
+ list = list.filter(
454
+ (d: Document) => new Date(d.updatedAt).getTime() <= toTime,
455
+ );
456
+ }
457
+
458
+ return list;
459
+ });
460
+
461
+ // Build grouped sections for folders (grouped by category)
462
+ const visibleSections = $derived.by(() => {
463
+ const byCategory = new Map<string, FolderType[]>();
464
+ for (const cat of data.categories) byCategory.set(cat.id, []);
465
+ const uncategorized: FolderType[] = [];
466
+
467
+ for (const folder of filteredFolders) {
468
+ if (folder.categoryId && byCategory.has(folder.categoryId)) {
469
+ byCategory.get(folder.categoryId)?.push(folder);
470
+ } else {
471
+ uncategorized.push(folder);
472
+ }
473
+ }
474
+
475
+ const items: Array<{
476
+ key: string;
477
+ category: Category | null;
478
+ folders: FolderType[];
479
+ }> = [];
480
+ for (const cat of data.categories) {
481
+ items.push({
482
+ key: cat.id,
483
+ category: cat,
484
+ folders: byCategory.get(cat.id) ?? [],
485
+ });
486
+ }
487
+ items.push({
488
+ key: "__uncategorized__",
489
+ category: null,
490
+ folders: uncategorized,
491
+ });
492
+
493
+ // If filtering by a single category, only show that category section!
494
+ if (activeCategoryId) {
495
+ return items.filter((s) => s.key === activeCategoryId);
496
+ }
497
+
498
+ return items.filter((s) => s.folders.length > 0);
499
+ });
500
+
501
+ const isRootEmpty = $derived(
502
+ filteredFolders.length === 0 && filteredDocuments.length === 0,
503
+ );
504
+
505
+ const isFolderEmpty = $derived(
506
+ activeFolderId &&
507
+ (data.activeFolder?.children?.length ?? 0) === 0 &&
508
+ (data.activeFolder?.documents?.length ?? 0) === 0,
509
+ );
510
+ </script>
511
+
512
+ <svelte:head>
513
+ <title>
514
+ {activeFolderId
515
+ ? m.folder_page_title({ name: data.activeFolder?.name || "Folder" })
516
+ : activeCategoryId
517
+ ? (data.categories.find((c: Category) => c.id === activeCategoryId)?.name || "Category")
518
+ : m.dashboard_page_title()}
519
+ </title>
520
+ </svelte:head>
521
+
522
+ <div class="dashboard-shell mx-auto max-w-5xl px-6 py-8">
523
+ <!-- Header -->
524
+ <div class="dashboard-header mb-8 flex flex-wrap items-center justify-between gap-4">
525
+ <div class="dashboard-context-identity flex items-center gap-3 flex-1 min-w-0">
526
+ {#if activeFolderId}
527
+ <Button
528
+ variant="ghost"
529
+ size="icon"
530
+ class="size-8 shrink-0"
531
+ onclick={() => {
532
+ const parentId = data.activeFolder?.parentId;
533
+ if (parentId) {
534
+ goto(`/?folder=${parentId}`);
535
+ } else {
536
+ goto("/");
537
+ }
538
+ }}
539
+ title={m.action_back()}
540
+ >
541
+ <ArrowLeft class="size-4" />
542
+ </Button>
543
+ <FolderOpen class="size-7 shrink-0 text-primary" />
544
+ <h1 class="text-2xl font-semibold tracking-tight truncate">
545
+ {data.activeFolder?.name || "Folder"}
546
+ </h1>
547
+ {@const activeFolderCategoryId = data.activeFolder?.categoryId}
548
+ {#if activeFolderCategoryId}
549
+ {@const cat = data.categories.find((c: Category) => c.id === activeFolderCategoryId)}
550
+ {#if cat}
551
+ <Badge variant="secondary">{cat.name}</Badge>
552
+ {/if}
553
+ {/if}
554
+ {:else if activeCategoryId}
555
+ <Button
556
+ variant="ghost"
557
+ size="icon"
558
+ class="size-8 shrink-0"
559
+ onclick={() => goto("/")}
560
+ title={m.action_back()}
561
+ >
562
+ <ArrowLeft class="size-4" />
563
+ </Button>
564
+ <FolderKanban class="size-7 shrink-0 text-primary" />
565
+ <h1 class="text-2xl font-semibold tracking-tight truncate">
566
+ {data.categories.find((c: Category) => c.id === activeCategoryId)?.name || "Category"}
567
+ </h1>
568
+ {:else}
569
+ <div class="dashboard-identity flex items-center gap-3">
570
+ <div class="flex size-10 items-center justify-center rounded-lg bg-primary/10">
571
+ <LayoutDashboard class="size-5 text-primary" />
572
+ </div>
573
+ <div>
574
+ <h1 class="text-2xl font-semibold tracking-tight">{m.dashboard_title()}</h1>
575
+ <p class="text-sm text-muted-foreground">{m.dashboard_subtitle()}</p>
576
+ </div>
577
+ </div>
578
+ {/if}
579
+ </div>
580
+
581
+ <!-- Top Action Buttons -->
582
+ <div class="dashboard-header-actions ml-auto flex items-center justify-end gap-2">
583
+ <input
584
+ type="file"
585
+ accept=".md,.txt,.json,.markdown,.docx"
586
+ multiple
587
+ class="hidden"
588
+ bind:this={importInput}
589
+ onchange={handleImportFile}
590
+ />
591
+ <Button variant="outline" size="sm" onclick={triggerImport} class="text-muted-foreground" aria-label={m.dashboard_import()}>
592
+ <Upload class="size-4" />
593
+ <span class="dashboard-action-label">{m.dashboard_import()}</span>
594
+ </Button>
595
+ <Button size="sm" onclick={handleNewDocument} aria-label={m.dashboard_new_document()}>
596
+ <Plus class="size-4" />
597
+ <span class="dashboard-action-label">{m.dashboard_new_document()}</span>
598
+ </Button>
599
+ {#if activeFolderId && data.activeFolder}
600
+ <Button variant="outline" size="sm" onclick={() => openShareDialogForFolder(activeFolderId, data.activeFolder?.name || "Folder")} aria-label={m.doc_share()}>
601
+ <Share2 class="size-3.5" />
602
+ <span class="dashboard-action-label">{m.doc_share()}</span>
603
+ </Button>
604
+ {:else if activeCategoryId}
605
+ {@const activeCategory = data.categories.find((category: Category) => category.id === activeCategoryId)}
606
+ {#if activeCategory}
607
+ <Button variant="outline" size="sm" onclick={() => openShareDialogForCategory(activeCategory.id, activeCategory.name)} aria-label={m.doc_share()}>
608
+ <Share2 class="size-3.5" />
609
+ <span class="dashboard-action-label">{m.doc_share()}</span>
610
+ </Button>
611
+ {/if}
612
+ {/if}
613
+ </div>
614
+ </div>
615
+
616
+ {#if dashboardWidgets.length > 0}
617
+ <section
618
+ class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2"
619
+ data-hiai-docs-extension-zone="dashboard-widgets"
620
+ aria-label="Dashboard extensions"
621
+ >
622
+ {#each dashboardWidgets as widget (widget.id)}
623
+ {@const Widget = widget.component}
624
+ <div
625
+ class="rounded-xl border border-border bg-card p-4"
626
+ data-hiai-docs-extension-id={widget.id}
627
+ >
628
+ {#if widget.title}
629
+ <h2 class="mb-3 text-sm font-semibold">{widget.title}</h2>
630
+ {/if}
631
+ <Widget userId={extensionContext.userId} />
632
+ </div>
633
+ {/each}
634
+ </section>
635
+ {/if}
636
+
637
+ <!-- Breadcrumbs (for folder detail view) -->
638
+ {#if activeFolderId && data.breadcrumb?.length > 0}
639
+ <nav class="mb-6 flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground" aria-label="Breadcrumbs">
640
+ <a href="/" class="hover:text-foreground transition-colors">Home</a>
641
+ {#each data.breadcrumb as path, idx (path.id)}
642
+ <ChevronRight class="size-3.5" />
643
+ {#if idx === data.breadcrumb.length - 1}
644
+ <span class="font-medium text-foreground truncate max-w-[150px]">{path.name}</span>
645
+ {:else}
646
+ <a href="/?folder={path.id}" class="hover:text-foreground transition-colors truncate max-w-[150px]">
647
+ {path.name}
648
+ </a>
649
+ {/if}
650
+ {/each}
651
+ </nav>
652
+ {/if}
653
+
654
+ <!-- Search & Filters Grid Zone -->
655
+ <div class="mb-8 grid grid-cols-1 gap-4 rounded-xl border border-border bg-card p-4 sm:grid-cols-2 lg:grid-cols-5">
656
+ <!-- Local Search Input -->
657
+ <div class="space-y-1.5">
658
+ <Label for="dash-search" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Search</Label>
659
+ <input
660
+ id="dash-search"
661
+ type="text"
662
+ bind:value={searchQuery}
663
+ oninput={updateFilters}
664
+ placeholder="Filter current view..."
665
+ class="h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
666
+ />
667
+ </div>
668
+
669
+ <!-- Category selector (only shown at root workspace) -->
670
+ <div class="space-y-1.5">
671
+ <Label for="dash-category" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Category</Label>
672
+ {#if activeFolderId}
673
+ <div class="h-9 w-full rounded-md border border-input bg-muted/50 px-3 py-2 text-sm shadow-sm opacity-60 flex items-center select-none">
674
+ Inherited from folder
675
+ </div>
676
+ {:else}
677
+ <Select.Root
678
+ type="single"
679
+ value={activeCategoryId ?? "all"}
680
+ onValueChange={(val: string) => {
681
+ const params = new URLSearchParams(page.url.searchParams);
682
+ if (val && val !== "all") params.set("category", val);
683
+ else params.delete("category");
684
+ goto(`/?${params.toString()}`);
685
+ }}
686
+ >
687
+ <Select.Trigger class="w-full text-foreground flex items-center justify-between bg-background border border-input px-3 py-2 text-sm rounded-md shadow-sm h-9">
688
+ <Select.Value placeholder="All Categories">
689
+ {activeCategoryId ? (data.categories.find((c: Category) => c.id === activeCategoryId)?.name ?? "All Categories") : "All Categories"}
690
+ </Select.Value>
691
+ <ChevronDown class="size-4 opacity-50" />
692
+ </Select.Trigger>
693
+ <Select.Content class="w-[var(--bits-select-trigger-width)]">
694
+ <Select.Item value="all">All Categories</Select.Item>
695
+ {#each data.categories as cat (cat.id)}
696
+ <Select.Item value={cat.id}>{cat.name}</Select.Item>
697
+ {/each}
698
+ </Select.Content>
699
+ </Select.Root>
700
+ {/if}
701
+ </div>
702
+
703
+ <!-- Tag Dropdown Selection -->
704
+ <div class="space-y-1.5">
705
+ <Label for="dash-tag" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Tag</Label>
706
+ <Select.Root
707
+ type="single"
708
+ value={selectedTagId ?? "all"}
709
+ onValueChange={(val: string) => {
710
+ selectedTagId = val === "all" ? null : val;
711
+ updateFilters();
712
+ }}
713
+ >
714
+ <Select.Trigger class="w-full text-foreground flex items-center justify-between bg-background border border-input px-3 py-2 text-sm rounded-md shadow-sm h-9">
715
+ <Select.Value placeholder="All Tags">
716
+ {selectedTagId ? (data.tags.find((t: ApiTag) => t.id === selectedTagId)?.name ?? "All Tags") : "All Tags"}
717
+ </Select.Value>
718
+ <ChevronDown class="size-4 opacity-50" />
719
+ </Select.Trigger>
720
+ <Select.Content class="w-[var(--bits-select-trigger-width)]">
721
+ <Select.Item value="all">All Tags</Select.Item>
722
+ {#each data.tags as tag (tag.id)}
723
+ <Select.Item value={tag.id}>{tag.name}</Select.Item>
724
+ {/each}
725
+ </Select.Content>
726
+ </Select.Root>
727
+ </div>
728
+
729
+ <!-- Date From -->
730
+ <div class="space-y-1.5">
731
+ <Label for="date-from" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">From Date</Label>
732
+ <DatePicker
733
+ id="date-from"
734
+ bind:value={dateFrom}
735
+ onchange={updateFilters}
736
+ placeholder="From Date"
737
+ />
738
+ </div>
739
+
740
+ <!-- Date To -->
741
+ <div class="space-y-1.5">
742
+ <Label for="date-to" class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">To Date</Label>
743
+ <DatePicker
744
+ id="date-to"
745
+ bind:value={dateTo}
746
+ onchange={updateFilters}
747
+ placeholder="To Date"
748
+ />
749
+ </div>
750
+ </div>
751
+
752
+ <!-- Active Filters Summary & Clear Filters -->
753
+ {#if hasActiveFilters}
754
+ <div class="mb-6 flex flex-wrap items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-2 text-sm">
755
+ <span class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Filters:</span>
756
+ {#if searchQuery.trim()}
757
+ <Badge variant="secondary" class="flex items-center gap-1">
758
+ <span>Search: {searchQuery}</span>
759
+ <button onclick={() => { searchQuery = ""; updateFilters(); }} class="text-muted-foreground hover:text-foreground">
760
+ <X class="size-3" />
761
+ </button>
762
+ </Badge>
763
+ {/if}
764
+ {#if selectedTagId}
765
+ {@const tag = data.tags.find((t: ApiTag) => t.id === selectedTagId)}
766
+ {#if tag}
767
+ <Badge variant="secondary" class="flex items-center gap-1.5">
768
+ <span class="size-2 rounded-full" style="background-color: {tag.color || '#cccccc'}"></span>
769
+ <span>Tag: {tag.name}</span>
770
+ <button onclick={() => { selectedTagId = null; updateFilters(); }} class="text-muted-foreground hover:text-foreground">
771
+ <X class="size-3" />
772
+ </button>
773
+ </Badge>
774
+ {/if}
775
+ {/if}
776
+ {#if dateFrom || dateTo}
777
+ <Badge variant="secondary" class="flex items-center gap-1">
778
+ <span>Date: {dateFrom || "*"} to {dateTo || "*"}</span>
779
+ <button onclick={() => { dateFrom = ""; dateTo = ""; updateFilters(); }} class="text-muted-foreground hover:text-foreground">
780
+ <X class="size-3" />
781
+ </button>
782
+ </Badge>
783
+ {/if}
784
+ <button
785
+ onclick={clearFilters}
786
+ class="ml-auto inline-flex items-center gap-1 text-xs font-semibold text-destructive hover:underline"
787
+ >
788
+ <RotateCcw class="size-3" />
789
+ Clear Filters
790
+ </button>
791
+ </div>
792
+ {/if}
793
+
794
+ <!-- MAIN CONTENT AREA -->
795
+ {#if activeFolderId}
796
+ <!-- ================= FOLDER VIEW ================= -->
797
+ {#if isFolderEmpty}
798
+ <!-- Empty folder state -->
799
+ <div class="flex flex-col items-center justify-center py-20 text-center">
800
+ <div class="mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
801
+ <FolderOpen class="size-8 text-muted-foreground" />
802
+ </div>
803
+ <h2 class="mb-2 text-lg font-semibold">Folder is empty</h2>
804
+ <p class="mb-6 max-w-sm text-sm text-muted-foreground">
805
+ Create a new document, subfolder, or drag and drop items here to get started.
806
+ </p>
807
+ </div>
808
+ {:else}
809
+ <!-- Subfolders List -->
810
+ {#if filteredFolders.length > 0}
811
+ <div class="mb-8">
812
+ <h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
813
+ Subfolders
814
+ </h2>
815
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
816
+ {#each filteredFolders as folder (folder.id)}
817
+ <FolderCard
818
+ {folder}
819
+ onDelete={handleDeleteFolder}
820
+ onRename={handleRenameFolder}
821
+ onShare={openShareDialogForFolder}
822
+ />
823
+ {/each}
824
+ </div>
825
+ </div>
826
+ {/if}
827
+
828
+ <!-- Documents List -->
829
+ {#if filteredDocuments.length > 0}
830
+ <div>
831
+ <h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
832
+ {m.nav_documents()}
833
+ </h2>
834
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
835
+ {#each filteredDocuments as doc (doc.id)}
836
+ <DocumentCard
837
+ document={doc}
838
+ onDelete={handleDeleteDocument}
839
+ onDuplicate={handleDuplicateDocument}
840
+ onShare={openShareDialogForDocument}
841
+ duplicateBusy={duplicatingDocumentId === doc.id}
842
+ />
843
+ {/each}
844
+ </div>
845
+ </div>
846
+ {/if}
847
+ {/if}
848
+
849
+ {:else}
850
+ <!-- ================= DASHBOARD / ROOT VIEW ================= -->
851
+ {#if isRootEmpty}
852
+ <!-- Empty Workspace State -->
853
+ <div class="flex flex-col items-center justify-center py-20 text-center">
854
+ <div class="mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
855
+ <FileText class="size-8 text-muted-foreground" />
856
+ </div>
857
+ <h2 class="mb-2 text-lg font-semibold">{m.folders_empty()}</h2>
858
+ <p class="mb-6 max-w-sm text-sm text-muted-foreground">
859
+ {m.folders_empty_description()}
860
+ </p>
861
+ </div>
862
+ {:else}
863
+ <!-- Grouped Category Sections of Folders -->
864
+ {#each visibleSections as section (section.key)}
865
+ {@const docSum = section.folders.reduce((acc, f) => acc + f.documentCount, 0)}
866
+ <section id="category-{section.key}" class="mb-8">
867
+ <div class="mb-3 flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
868
+ <h2>{section.category ? section.category.name : m.sidebar_uncategorized()}</h2>
869
+ <Badge variant="secondary" class="text-[10px]">
870
+ {docSum} {docSum === 1 ? 'file' : 'files'}
871
+ </Badge>
872
+ {#if section.category}
873
+ <DropdownMenu>
874
+ <DropdownMenuTrigger class="ml-auto inline-flex size-8 items-center justify-center rounded-md hover:bg-accent" aria-label={m.editor_more_options()}>
875
+ <MoreVertical class="size-4" />
876
+ </DropdownMenuTrigger>
877
+ <DropdownMenuContent align="end">
878
+ <DropdownMenuItem onSelect={() => openShareDialogForCategory(section.category!.id, section.category!.name)}>
879
+ <Share2 class="size-4" />
880
+ {m.doc_share()}
881
+ </DropdownMenuItem>
882
+ </DropdownMenuContent>
883
+ </DropdownMenu>
884
+ {/if}
885
+ </div>
886
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
887
+ {#each section.folders as folder (folder.id)}
888
+ <FolderCard
889
+ {folder}
890
+ onDelete={handleDeleteFolder}
891
+ onRename={handleRenameFolder}
892
+ onShare={openShareDialogForFolder}
893
+ />
894
+ {/each}
895
+ </div>
896
+ </section>
897
+ {/each}
898
+
899
+ <!-- Recent Documents Section (shown at the bottom) -->
900
+ {#if filteredDocuments.length > 0}
901
+ <div class="mt-12 border-t border-border/60 pt-8">
902
+ <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
903
+ {hasActiveFilters ? "Filtered Documents" : "Recent Documents"}
904
+ </h2>
905
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
906
+ {#each filteredDocuments as doc (doc.id)}
907
+ <DocumentCard
908
+ document={doc}
909
+ onDelete={handleDeleteDocument}
910
+ onDuplicate={handleDuplicateDocument}
911
+ onShare={openShareDialogForDocument}
912
+ duplicateBusy={duplicatingDocumentId === doc.id}
913
+ />
914
+ {/each}
915
+ </div>
916
+ </div>
917
+ {/if}
918
+ {/if}
919
+ {/if}
920
+ </div>
921
+
922
+ <style>
923
+ .dashboard-shell {
924
+ position: relative;
925
+ }
926
+
927
+ @media (max-width: 767px) {
928
+ .dashboard-header {
929
+ min-height: 44px;
930
+ }
931
+
932
+ .dashboard-header-actions {
933
+ position: absolute;
934
+ top: calc(0.75rem + env(safe-area-inset-top));
935
+ right: 0.75rem;
936
+ z-index: 10;
937
+ }
938
+
939
+ .dashboard-context-identity {
940
+ margin-left: 56px;
941
+ min-width: 0;
942
+ }
943
+ }
944
+
945
+ @media (max-width: 520px) {
946
+ .dashboard-header-actions :global(button) {
947
+ width: 40px;
948
+ height: 40px;
949
+ padding: 0;
950
+ }
951
+
952
+ .dashboard-action-label {
953
+ display: none;
954
+ }
955
+ }
956
+
957
+ @media (max-width: 449px) {
958
+ .dashboard-context-identity {
959
+ margin-left: 52px;
960
+ }
961
+
962
+ .dashboard-context-identity p {
963
+ display: none;
964
+ }
965
+
966
+ .dashboard-context-identity h1 {
967
+ font-size: 1.25rem;
968
+ }
969
+ }
970
+ </style>
971
+
972
+ <!-- Folder creation / renaming dialog -->
973
+ <FolderDialog
974
+ bind:open={showFolderDialog}
975
+ mode={folderDialogMode}
976
+ folder={folderDialogTarget}
977
+ onSave={saveFolder}
978
+ />
979
+
980
+ <!-- Delete folder confirmation dialog -->
981
+ <ConfirmDialog
982
+ bind:open={showDeleteFolderDialog}
983
+ title={m.folders_delete_title()}
984
+ description={m.folders_delete_description()}
985
+ confirmLabel={m.folders_delete()}
986
+ cancelLabel={m.action_cancel()}
987
+ variant="destructive"
988
+ busy={deleteFolderBusy}
989
+ onConfirm={confirmDeleteFolder}
990
+ onCancel={() => (showDeleteFolderDialog = false)}
991
+ />
992
+
993
+ <!-- Shared dialog for document, folder, and category entry points. -->
994
+ {#if shareTarget}
995
+ <ShareDialog
996
+ bind:open={showShareDialog}
997
+ documentId={shareTarget.kind === "document" ? shareTarget.documentId : ""}
998
+ documentTitle={shareTarget.kind === "document" ? shareTarget.title : ""}
999
+ folderId={shareTarget.kind === "folder" ? shareTarget.folderId : ""}
1000
+ folderName={shareTarget.kind === "folder" ? shareTarget.name : ""}
1001
+ categoryId={shareTarget.kind === "category" ? shareTarget.categoryId : ""}
1002
+ categoryName={shareTarget.kind === "category" ? shareTarget.name : ""}
1003
+ />
1004
+ {/if}
1005
+
1006
+ <!-- Multi-file import progress dialog overlay -->
1007
+ <ImportProgress open={importOpen} items={importItems} onClose={closeImport} />